chatccc 0.2.62 → 0.2.64

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.
Files changed (35) hide show
  1. package/config.sample.json +2 -3
  2. package/im-skills/feishu-skill/download-video.mjs +10 -17
  3. package/im-skills/wechat-file-skill/receive-send-file.md +39 -0
  4. package/im-skills/wechat-file-skill/send-file.mjs +84 -0
  5. package/im-skills/wechat-file-skill/skill.md +11 -0
  6. package/im-skills/{wechat-skill → wechat-image-skill}/receive-send-image.md +39 -39
  7. package/im-skills/{wechat-skill → wechat-image-skill}/send-image.mjs +84 -80
  8. package/im-skills/{wechat-skill → wechat-image-skill}/skill.md +11 -11
  9. package/im-skills/wechat-video-skill/receive-send-video.md +39 -0
  10. package/im-skills/wechat-video-skill/send-video.mjs +80 -0
  11. package/im-skills/wechat-video-skill/skill.md +11 -0
  12. package/package.json +59 -59
  13. package/src/__tests__/agent-platform-routing.test.ts +26 -0
  14. package/src/__tests__/claude-adapter.test.ts +48 -48
  15. package/src/__tests__/config-reload.test.ts +14 -14
  16. package/src/__tests__/config-sample.test.ts +22 -22
  17. package/src/__tests__/im-skills.test.ts +112 -3
  18. package/src/__tests__/orchestrator.test.ts +1 -1
  19. package/src/__tests__/privacy.test.ts +142 -142
  20. package/src/__tests__/simplify.test.ts +282 -282
  21. package/src/__tests__/web-ui.test.ts +23 -23
  22. package/src/__tests__/wechat-platform.test.ts +65 -0
  23. package/src/adapters/claude-adapter.ts +40 -40
  24. package/src/agent-file-rpc.ts +19 -4
  25. package/src/agent-image-rpc.ts +19 -4
  26. package/src/agent-platform-routing.ts +28 -0
  27. package/src/config.ts +2 -24
  28. package/src/im-skills.ts +9 -0
  29. package/src/index.ts +11 -6
  30. package/src/orchestrator.ts +2 -2
  31. package/src/privacy.ts +67 -67
  32. package/src/session.ts +21 -5
  33. package/src/simplify.ts +119 -119
  34. package/src/web-ui.ts +3 -26
  35. package/src/wechat-platform.ts +170 -30
package/src/session.ts CHANGED
@@ -92,6 +92,17 @@ export function _getPlatformForChatForTest(chatId: string): PlatformAdapter | nu
92
92
  return platformForChat(chatId);
93
93
  }
94
94
 
95
+ export function getPlatformForChat(chatId: string): PlatformAdapter | null {
96
+ return platformForChat(chatId);
97
+ }
98
+
99
+ function imSkillNamesForPlatform(platform: PlatformAdapter): string[] {
100
+ if (platform.kind === "wechat") {
101
+ return ["wechat-image-skill", "wechat-file-skill", "wechat-video-skill"];
102
+ }
103
+ return ["feishu-skill"];
104
+ }
105
+
95
106
  export let sessionGen = 0;
96
107
  /** @deprecated 使用 activePrompts (session-chat-binding.ts) + displayCards 替代 */
97
108
  export const chatSessionMap = new Map<string, {
@@ -675,7 +686,7 @@ export async function runAgentSession(
675
686
  const isWechatBusy = platform.kind === "wechat";
676
687
  const busyMsg = isWechatBusy
677
688
  ? "当前正在生成回复中,请等待完成后再发送消息。如需中断生成,请发送 /stop 指令。"
678
- : "该会话正在生成回复中,请等待完成后再发送消息。";
689
+ : "该会话正在生成回复中,请等待完成后再发送消息。如需中断生成,请发送 /stop 指令。";
679
690
  await platform.sendText(_chatId, busyMsg).catch(() => {});
680
691
  return;
681
692
  }
@@ -705,7 +716,9 @@ export async function runAgentSession(
705
716
 
706
717
  // 构建 IM skills prompt(sessionId 方式,无 token)
707
718
  const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
708
- const wechatSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-skill");
719
+ const wechatImageSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-image-skill");
720
+ const wechatFileSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-file-skill");
721
+ const wechatVideoSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-video-skill");
709
722
  const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
710
723
  const skillVariables = {
711
724
  cwd,
@@ -716,10 +729,13 @@ export async function runAgentSession(
716
729
  send_image_script: join(feishuSkillDir, "send-image.mjs"),
717
730
  send_file_script: join(feishuSkillDir, "send-file.mjs"),
718
731
  download_video_script: join(feishuSkillDir, "download-video.mjs"),
719
- wechat_send_image_script: join(wechatSkillDir, "send-image.mjs"),
732
+ wechat_send_image_script: join(wechatImageSkillDir, "send-image.mjs"),
733
+ wechat_send_file_script: join(wechatFileSkillDir, "send-file.mjs"),
734
+ wechat_send_video_script: join(wechatVideoSkillDir, "send-video.mjs"),
720
735
  };
721
- var imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
722
- await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
736
+ const enabledSkillNames = imSkillNamesForPlatform(platform);
737
+ var imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables, enabledSkillNames });
738
+ await exportSkillSubDocs({ variables: skillVariables, enabledSkillNames }, imSkillsCacheDir);
723
739
  var userTextWithCapabilities = [
724
740
  ...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
725
741
  "[User message]",
package/src/simplify.ts CHANGED
@@ -1,120 +1,120 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { resolve as resolvePath } from "node:path";
3
- import { PROJECT_ROOT } from "./config.ts";
4
-
5
- // ---------------------------------------------------------------------------
6
- // 消息简化规则 —— 数据驱动,在 simplify.json 中配置
7
- // ---------------------------------------------------------------------------
8
-
9
- interface ToolRule {
10
- template: string;
11
- maxLength: number;
12
- }
13
-
14
- interface SimplifyConfig {
15
- tool_use?: Record<string, ToolRule>;
16
- tool_result?: Record<string, ToolRule>;
17
- }
18
-
19
- let config: SimplifyConfig | null = null;
20
- let loaded = false;
21
-
22
- function loadConfig(): SimplifyConfig {
23
- const filePath = resolvePath(PROJECT_ROOT, "simplify.json");
24
- if (!existsSync(filePath)) return {};
25
- try {
26
- const raw = readFileSync(filePath, "utf-8");
27
- const parsed = JSON.parse(raw);
28
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
29
- console.error("[SIMPLIFY] simplify.json 格式错误:应为对象");
30
- return {};
31
- }
32
- return parsed as SimplifyConfig;
33
- } catch (err) {
34
- console.error(`[SIMPLIFY] 读取 simplify.json 失败: ${(err as Error).message}`);
35
- return {};
36
- }
37
- }
38
-
39
- function getConfig(): SimplifyConfig {
40
- if (!loaded) {
41
- config = loadConfig();
42
- loaded = true;
43
- }
44
- return config!;
45
- }
46
-
47
- /** 热重载 */
48
- export function reloadSimplifyConfig(): void {
49
- loaded = false;
50
- config = null;
51
- }
52
-
53
- /**
54
- * 对模板字符串中的 {field} 占位符做替换。
55
- * fields 为可用字段映射,extra 是额外的上下文(如 tool_use_id 的 id)。
56
- */
57
- function resolveTemplate(template: string, fields: Record<string, unknown>, extra?: Record<string, string>): string {
58
- let result = template;
59
- if (extra) {
60
- for (const [k, v] of Object.entries(extra)) {
61
- result = result.split(`{${k}}`).join(v);
62
- }
63
- }
64
- for (const [k, v] of Object.entries(fields)) {
65
- const strVal = typeof v === "string" ? v : JSON.stringify(v);
66
- result = result.split(`{${k}}`).join(strVal);
67
- }
68
- return result;
69
- }
70
-
71
- /**
72
- * 简化 tool_use 展示。
73
- * 返回 null 表示无规则,调用方应回退到默认格式化。
74
- */
75
- export function simplifyToolUse(name: string, input: unknown): string | null {
76
- const cfg = getConfig();
77
- const rules = cfg.tool_use;
78
- if (!rules) return null;
79
- const rule = rules[name];
80
- if (!rule) return null;
81
-
82
- const fields = typeof input === "object" && input !== null
83
- ? input as Record<string, unknown>
84
- : {};
85
- let result = resolveTemplate(rule.template, fields);
86
- if (result.length > rule.maxLength) {
87
- result = result.slice(0, rule.maxLength) + "...";
88
- }
89
- return result;
90
- }
91
-
92
- /**
93
- * 简化 tool_result 展示。
94
- * 返回 null 表示无规则,调用方应回退到默认格式化。
95
- * toolCallInput 为对应的 tool_use 输入(可选),用于在 result 模板中引用输入字段。
96
- */
97
- export function simplifyToolResult(
98
- name: string,
99
- toolUseId: string,
100
- isError: boolean,
101
- toolCallInput?: unknown,
102
- ): string | null {
103
- const cfg = getConfig();
104
- const rules = cfg.tool_result;
105
- if (!rules) return null;
106
- const rule = rules[name];
107
- if (!rule) return null;
108
-
109
- const id = toolUseId.slice(-6);
110
- const extra = { id };
111
- const fields = toolCallInput && typeof toolCallInput === "object"
112
- ? toolCallInput as Record<string, unknown>
113
- : {};
114
- let result = resolveTemplate(rule.template, fields, extra);
115
- if (isError) result = "❌ " + result;
116
- if (result.length > rule.maxLength) {
117
- result = result.slice(0, rule.maxLength) + "...";
118
- }
119
- return result;
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve as resolvePath } from "node:path";
3
+ import { PROJECT_ROOT } from "./config.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // 消息简化规则 —— 数据驱动,在 simplify.json 中配置
7
+ // ---------------------------------------------------------------------------
8
+
9
+ interface ToolRule {
10
+ template: string;
11
+ maxLength: number;
12
+ }
13
+
14
+ interface SimplifyConfig {
15
+ tool_use?: Record<string, ToolRule>;
16
+ tool_result?: Record<string, ToolRule>;
17
+ }
18
+
19
+ let config: SimplifyConfig | null = null;
20
+ let loaded = false;
21
+
22
+ function loadConfig(): SimplifyConfig {
23
+ const filePath = resolvePath(PROJECT_ROOT, "simplify.json");
24
+ if (!existsSync(filePath)) return {};
25
+ try {
26
+ const raw = readFileSync(filePath, "utf-8");
27
+ const parsed = JSON.parse(raw);
28
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
29
+ console.error("[SIMPLIFY] simplify.json 格式错误:应为对象");
30
+ return {};
31
+ }
32
+ return parsed as SimplifyConfig;
33
+ } catch (err) {
34
+ console.error(`[SIMPLIFY] 读取 simplify.json 失败: ${(err as Error).message}`);
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function getConfig(): SimplifyConfig {
40
+ if (!loaded) {
41
+ config = loadConfig();
42
+ loaded = true;
43
+ }
44
+ return config!;
45
+ }
46
+
47
+ /** 热重载 */
48
+ export function reloadSimplifyConfig(): void {
49
+ loaded = false;
50
+ config = null;
51
+ }
52
+
53
+ /**
54
+ * 对模板字符串中的 {field} 占位符做替换。
55
+ * fields 为可用字段映射,extra 是额外的上下文(如 tool_use_id 的 id)。
56
+ */
57
+ function resolveTemplate(template: string, fields: Record<string, unknown>, extra?: Record<string, string>): string {
58
+ let result = template;
59
+ if (extra) {
60
+ for (const [k, v] of Object.entries(extra)) {
61
+ result = result.split(`{${k}}`).join(v);
62
+ }
63
+ }
64
+ for (const [k, v] of Object.entries(fields)) {
65
+ const strVal = typeof v === "string" ? v : JSON.stringify(v);
66
+ result = result.split(`{${k}}`).join(strVal);
67
+ }
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * 简化 tool_use 展示。
73
+ * 返回 null 表示无规则,调用方应回退到默认格式化。
74
+ */
75
+ export function simplifyToolUse(name: string, input: unknown): string | null {
76
+ const cfg = getConfig();
77
+ const rules = cfg.tool_use;
78
+ if (!rules) return null;
79
+ const rule = rules[name];
80
+ if (!rule) return null;
81
+
82
+ const fields = typeof input === "object" && input !== null
83
+ ? input as Record<string, unknown>
84
+ : {};
85
+ let result = resolveTemplate(rule.template, fields);
86
+ if (result.length > rule.maxLength) {
87
+ result = result.slice(0, rule.maxLength) + "...";
88
+ }
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * 简化 tool_result 展示。
94
+ * 返回 null 表示无规则,调用方应回退到默认格式化。
95
+ * toolCallInput 为对应的 tool_use 输入(可选),用于在 result 模板中引用输入字段。
96
+ */
97
+ export function simplifyToolResult(
98
+ name: string,
99
+ toolUseId: string,
100
+ isError: boolean,
101
+ toolCallInput?: unknown,
102
+ ): string | null {
103
+ const cfg = getConfig();
104
+ const rules = cfg.tool_result;
105
+ if (!rules) return null;
106
+ const rule = rules[name];
107
+ if (!rule) return null;
108
+
109
+ const id = toolUseId.slice(-6);
110
+ const extra = { id };
111
+ const fields = toolCallInput && typeof toolCallInput === "object"
112
+ ? toolCallInput as Record<string, unknown>
113
+ : {};
114
+ let result = resolveTemplate(rule.template, fields, extra);
115
+ if (isError) result = "❌ " + result;
116
+ if (result.length > rule.maxLength) {
117
+ result = result.slice(0, rule.maxLength) + "...";
118
+ }
119
+ return result;
120
120
  }
package/src/web-ui.ts CHANGED
@@ -342,9 +342,6 @@ export function unflattenConfig(flat: Record<string, unknown>): Record<string, u
342
342
  } else if (key === "CHATCCC_APP_SECRET") {
343
343
  result.feishu = result.feishu || {};
344
344
  (result.feishu as Record<string, unknown>).appSecret = val;
345
- } else if (key === "CHATCCC_FEISHU_DOMAIN") {
346
- result.feishu = result.feishu || {};
347
- (result.feishu as Record<string, unknown>).domain = val;
348
345
  } else if (key === "CHATCCC_FEISHU_ENABLED") {
349
346
  result.platforms = result.platforms || {};
350
347
  (result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
@@ -642,14 +639,6 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
642
639
  <input type="password" id="field-CHATCCC_APP_SECRET" placeholder="...">
643
640
  <div class="hint">飞书开放平台「凭证与基础信息」→ App Secret</div>
644
641
  </div>
645
- <div class="form-group">
646
- <label>平台域名</label>
647
- <select id="field-CHATCCC_FEISHU_DOMAIN">
648
- <option value="feishu">飞书 (open.feishu.cn)</option>
649
- <option value="lark">Lark (open.larksuite.com)</option>
650
- </select>
651
- <div class="hint">国际版 Lark 用户请选择 Lark</div>
652
- </div>
653
642
  </div>
654
643
  </div>
655
644
 
@@ -837,7 +826,6 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
837
826
  </div>
838
827
  <div class="config-row"><span class="key">App ID</span><span class="val" id="cfg-APP_ID">-</span></div>
839
828
  <div class="config-row"><span class="key">App Secret</span><span class="val" id="cfg-APP_SECRET">-</span></div>
840
- <div class="config-row"><span class="key">平台域名</span><span class="val" id="cfg-FEISHU_DOMAIN">-</span></div>
841
829
  <button class="btn btn-outline" style="margin-top:8px" onclick="editSection('feishu')">编辑</button>
842
830
  </div>
843
831
  </details>
@@ -938,7 +926,7 @@ const AGENT_FIELDS = {
938
926
  cursor: ['CHATCCC_CURSOR_PATH','CHATCCC_CURSOR_MODEL'],
939
927
  codex: ['CHATCCC_CODEX_PATH','CHATCCC_CODEX_MODEL','CHATCCC_CODEX_EFFORT']
940
928
  };
941
- const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET','CHATCCC_FEISHU_DOMAIN'];
929
+ const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
942
930
 
943
931
  // 当前选中的 Claude API 模式("official" / "thirdparty")
944
932
  // Wizard / Dashboard 都通过这个变量驱动 UI 显隐和提交时的 mode 字段
@@ -1209,7 +1197,6 @@ function renderStep1() {
1209
1197
  var f = c.feishu || {};
1210
1198
  prefillNested('field-CHATCCC_APP_ID', f.appId);
1211
1199
  prefillNested('field-CHATCCC_APP_SECRET', f.appSecret);
1212
- prefillNested('field-CHATCCC_FEISHU_DOMAIN', f.domain || 'feishu');
1213
1200
  // 平台开关:按已有 config 回填;首次配置(无飞书凭证)时默认关闭飞书、开启微信
1214
1201
  var hasExistingCreds = Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
1215
1202
  var feishuEnabled = hasExistingCreds
@@ -1362,7 +1349,6 @@ function renderStep3() {
1362
1349
  if (state.platformsEnabled.feishu) {
1363
1350
  lines.push('<div class="config-row"><span class="key">CHATCCC_APP_ID</span><span class="val">' + (vars.CHATCCC_APP_ID || '<span style="color:#ef4444">未填写</span>') + '</span></div>');
1364
1351
  lines.push('<div class="config-row"><span class="key">CHATCCC_APP_SECRET</span><span class="val">' + (vars.CHATCCC_APP_SECRET ? '***已设置***' : '<span style="color:#ef4444">未填写</span>') + '</span></div>');
1365
- lines.push('<div class="config-row"><span class="key">平台域名</span><span class="val">' + ((vars.CHATCCC_FEISHU_DOMAIN === 'lark') ? 'Lark (open.larksuite.com)' : '飞书 (open.feishu.cn)') + '</span></div>');
1366
1352
  }
1367
1353
 
1368
1354
  lines.push('<h3 style="margin:16px 0 8px">微信 iLink</h3>');
@@ -1548,7 +1534,6 @@ function updateDashboardUI() {
1548
1534
  state.platformsEnabled.feishu = feishuEnabled;
1549
1535
  document.getElementById('cfg-APP_ID').textContent = c.feishu && c.feishu.appId ? c.feishu.appId.slice(0,8) + '...' + c.feishu.appId.slice(-4) : '-';
1550
1536
  document.getElementById('cfg-APP_SECRET').textContent = c.feishu && c.feishu.appSecret ? '***已设置***' : '-';
1551
- document.getElementById('cfg-FEISHU_DOMAIN').textContent = (c.feishu && c.feishu.domain === 'lark') ? 'Lark (open.larksuite.com)' : '飞书 (open.feishu.cn)';
1552
1537
 
1553
1538
  // 只显示已启用的 Agent 卡片(按 enabled 字段;缺省时退回到"任一字段非空"兼容旧 config)
1554
1539
  var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
@@ -1651,7 +1636,7 @@ function editSection(section) {
1651
1636
 
1652
1637
  var html = '';
1653
1638
  var labelMap = {
1654
- 'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret', 'CHATCCC_FEISHU_DOMAIN': '平台域名',
1639
+ 'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret',
1655
1640
  'CLAUDE_API_KEY': 'API Key', 'CLAUDE_BASE_URL': 'Base URL',
1656
1641
  'CHATCCC_ANTHROPIC_MODEL': '模型', 'CHATCCC_ANTHROPIC_SUBAGENT_MODEL': 'Subagent 模型', 'CHATCCC_ANTHROPIC_EFFORT': 'Effort',
1657
1642
  'CHATCCC_CURSOR_PATH': 'CLI 路径', 'CHATCCC_CURSOR_MODEL': '模型',
@@ -1688,7 +1673,6 @@ function editSection(section) {
1688
1673
  if (section === 'feishu') {
1689
1674
  if (key === 'CHATCCC_APP_ID' && state.config.feishu) val = state.config.feishu.appId || '';
1690
1675
  else if (key === 'CHATCCC_APP_SECRET' && state.config.feishu) val = state.config.feishu.appSecret || '';
1691
- else if (key === 'CHATCCC_FEISHU_DOMAIN' && state.config.feishu) val = state.config.feishu.domain || 'feishu';
1692
1676
  } else if (section === 'claude' && state.config.claude) {
1693
1677
  if (key === 'CLAUDE_API_KEY') val = state.config.claude.apiKey || '';
1694
1678
  else if (key === 'CLAUDE_BASE_URL') val = state.config.claude.baseUrl || '';
@@ -1723,14 +1707,7 @@ function editSection(section) {
1723
1707
  var groupClass = 'form-group' + (isClaudeSubagentField && claudeApiMode !== 'thirdparty' ? ' hidden' : '');
1724
1708
  var subagentAttr = isClaudeSubagentField ? ' data-claude-subagent-field="1"' : '';
1725
1709
  html += '<div class="' + groupClass + '"' + subagentAttr + '><label>' + (labelMap[key] || key) + '</label>';
1726
- if (key === 'CHATCCC_FEISHU_DOMAIN') {
1727
- html += '<select id="edit-' + key + '">';
1728
- html += '<option value="feishu"' + (val === 'lark' ? '' : ' selected') + '>飞书 (open.feishu.cn)</option>';
1729
- html += '<option value="lark"' + (val === 'lark' ? ' selected' : '') + '>Lark (open.larksuite.com)</option>';
1730
- html += '</select>';
1731
- } else {
1732
- html += '<input type="' + (isSecret ? 'password' : 'text') + '" id="edit-' + key + '" value="' + String(val).replace(/"/g,'&quot;') + '">';
1733
- }
1710
+ html += '<input type="' + (isSecret ? 'password' : 'text') + '" id="edit-' + key + '" value="' + String(val).replace(/"/g,'&quot;') + '">';
1734
1711
  html += '</div>';
1735
1712
  });
1736
1713
 
@@ -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, extname, join } from "node:path";
13
+ import { basename, dirname, extname, join } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
 
16
16
  import {
@@ -19,7 +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
+ import type { FileItem, ImageItem, VideoItem } from "@openilink/openilink-sdk-node";
23
23
 
24
24
  import type { PlatformAdapter } from "./platform-adapter.ts";
25
25
  import { setupFileLogging } from "./shared.ts";
@@ -502,8 +502,31 @@ export async function startWechatPlatform(
502
502
  }
503
503
 
504
504
  const WECHAT_IMAGE_DOWNLOAD_DIR = join(homedir(), ".chatccc", "images", "downloads");
505
+ const WECHAT_FILE_DOWNLOAD_DIR = join(homedir(), ".chatccc", "files", "downloads");
506
+ const WECHAT_VIDEO_DOWNLOAD_DIR = join(homedir(), ".chatccc", "videos", "downloads");
505
507
 
506
- function extFromMimeOrName(mime?: string | null, fileName?: string | null): string {
508
+ type WechatMediaDownloader = Pick<OpenIlinkWire, "downloadMedia">;
509
+
510
+ interface WechatMediaDownloadOptions {
511
+ wire?: WechatMediaDownloader | null;
512
+ imageDir?: string;
513
+ fileDir?: string;
514
+ videoDir?: string;
515
+ log?: (msg: string) => void;
516
+ }
517
+
518
+ interface WechatDownloadedMediaAttachments {
519
+ imagePaths: string[];
520
+ filePaths: string[];
521
+ videoPaths: string[];
522
+ messageLines: string[];
523
+ }
524
+
525
+ function extFromMimeOrName(
526
+ mime?: string | null,
527
+ fileName?: string | null,
528
+ fallback = ".bin",
529
+ ): string {
507
530
  if (mime) {
508
531
  const map: Record<string, string> = {
509
532
  "image/png": ".png",
@@ -512,6 +535,17 @@ function extFromMimeOrName(mime?: string | null, fileName?: string | null): stri
512
535
  "image/gif": ".gif",
513
536
  "image/bmp": ".bmp",
514
537
  "image/svg+xml": ".svg",
538
+ "video/mp4": ".mp4",
539
+ "video/quicktime": ".mov",
540
+ "video/x-msvideo": ".avi",
541
+ "video/x-matroska": ".mkv",
542
+ "video/webm": ".webm",
543
+ "video/x-flv": ".flv",
544
+ "text/plain": ".txt",
545
+ "text/csv": ".csv",
546
+ "application/pdf": ".pdf",
547
+ "application/zip": ".zip",
548
+ "application/gzip": ".gz",
515
549
  };
516
550
  const key = mime.split(";")[0].trim().toLowerCase();
517
551
  if (map[key]) return map[key];
@@ -520,25 +554,145 @@ function extFromMimeOrName(mime?: string | null, fileName?: string | null): stri
520
554
  const ext = extname(fileName).toLowerCase();
521
555
  if (ext) return ext;
522
556
  }
523
- return ".png";
557
+ return fallback;
524
558
  }
525
559
 
526
- async function downloadWechatImage(imageItem: ImageItem, msgId?: number): Promise<string> {
527
- const wire = ilinkWire;
560
+ async function downloadWechatImage(
561
+ imageItem: ImageItem,
562
+ msgId?: number,
563
+ options: WechatMediaDownloadOptions = {},
564
+ ): Promise<string> {
565
+ const wire = options.wire ?? ilinkWire;
528
566
  if (!wire) throw new Error("iLink wire not available");
529
567
  if (!imageItem.media) throw new Error("image item has no media");
530
568
 
531
569
  const data = await wire.downloadMedia(imageItem.media);
532
570
  const mime = (imageItem as Record<string, unknown>).mime_type as string | undefined;
533
- const ext = extFromMimeOrName(mime);
571
+ const fileName = (imageItem as Record<string, unknown>).file_name as string | undefined;
572
+ const ext = extFromMimeOrName(mime, fileName, ".png");
534
573
  const key = imageItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
535
- await mkdirSync(WECHAT_IMAGE_DOWNLOAD_DIR, { recursive: true });
536
- const localPath = join(WECHAT_IMAGE_DOWNLOAD_DIR, `wx_${key}${ext}`);
574
+ const downloadDir = options.imageDir ?? WECHAT_IMAGE_DOWNLOAD_DIR;
575
+ mkdirSync(downloadDir, { recursive: true });
576
+ const localPath = join(downloadDir, `wx_${key}${ext}`);
537
577
  writeFileSync(localPath, data);
538
578
  platformLog(`图片已下载: ${localPath}`);
539
579
  return localPath;
540
580
  }
541
581
 
582
+ function safeLocalFileName(fileName: string): string {
583
+ return basename(fileName).replace(/[<>:"/\\|?*\x00-\x1F]/g, "_");
584
+ }
585
+
586
+ async function downloadWechatFile(
587
+ fileItem: FileItem,
588
+ msgId?: number,
589
+ options: WechatMediaDownloadOptions = {},
590
+ ): Promise<string> {
591
+ const wire = options.wire ?? ilinkWire;
592
+ if (!wire) throw new Error("iLink wire not available");
593
+ if (!fileItem.media) throw new Error("file item has no media");
594
+
595
+ const data = await wire.downloadMedia(fileItem.media);
596
+ const mime = (fileItem as Record<string, unknown>).mime_type as string | undefined;
597
+ const ext = extFromMimeOrName(mime, fileItem.file_name, ".bin");
598
+ const key =
599
+ fileItem.md5?.slice(0, 16) ??
600
+ fileItem.media.aes_key?.slice(0, 16) ??
601
+ (msgId?.toString() ?? Date.now().toString());
602
+ const localName = fileItem.file_name
603
+ ? `wx_${key}_${safeLocalFileName(fileItem.file_name)}`
604
+ : `wx_${key}${ext}`;
605
+ const downloadDir = options.fileDir ?? WECHAT_FILE_DOWNLOAD_DIR;
606
+ mkdirSync(downloadDir, { recursive: true });
607
+ const localPath = join(downloadDir, localName);
608
+ writeFileSync(localPath, data);
609
+ (options.log ?? platformLog)(`文件已下载: ${localPath}`);
610
+ return localPath;
611
+ }
612
+
613
+ async function downloadWechatVideo(
614
+ videoItem: VideoItem,
615
+ msgId?: number,
616
+ options: WechatMediaDownloadOptions = {},
617
+ ): Promise<string> {
618
+ const wire = options.wire ?? ilinkWire;
619
+ if (!wire) throw new Error("iLink wire not available");
620
+ if (!videoItem.media) throw new Error("video item has no media");
621
+
622
+ const data = await wire.downloadMedia(videoItem.media);
623
+ const mime = (videoItem as Record<string, unknown>).mime_type as string | undefined;
624
+ const fileName = (videoItem as Record<string, unknown>).file_name as string | undefined;
625
+ const ext = extFromMimeOrName(mime, fileName, ".mp4");
626
+ const key =
627
+ videoItem.video_md5?.slice(0, 16) ??
628
+ videoItem.media.aes_key?.slice(0, 16) ??
629
+ (msgId?.toString() ?? Date.now().toString());
630
+ const downloadDir = options.videoDir ?? WECHAT_VIDEO_DOWNLOAD_DIR;
631
+ mkdirSync(downloadDir, { recursive: true });
632
+ const localPath = join(downloadDir, `wx_${key}${ext}`);
633
+ writeFileSync(localPath, data);
634
+ (options.log ?? platformLog)(`视频已下载: ${localPath}`);
635
+ return localPath;
636
+ }
637
+
638
+ async function downloadWechatMediaAttachments(
639
+ message: WeixinMessage,
640
+ options: WechatMediaDownloadOptions = {},
641
+ ): Promise<WechatDownloadedMediaAttachments> {
642
+ const imagePaths: string[] = [];
643
+ const filePaths: string[] = [];
644
+ const videoPaths: string[] = [];
645
+ const messageLines: string[] = [];
646
+ const items = message.item_list;
647
+ if (items) {
648
+ for (const item of items) {
649
+ if (item.image_item?.media) {
650
+ try {
651
+ const localPath = await downloadWechatImage(item.image_item, message.message_id, options);
652
+ imagePaths.push(localPath);
653
+ messageLines.push(`[图片] ${localPath}`);
654
+ } catch (err) {
655
+ (options.log ?? platformLog)(`图片下载失败: ${(err as Error).message}`);
656
+ }
657
+ }
658
+
659
+ if (item.file_item?.media) {
660
+ try {
661
+ const localPath = await downloadWechatFile(item.file_item, message.message_id, options);
662
+ filePaths.push(localPath);
663
+ messageLines.push(`[文件] ${localPath}`);
664
+ } catch (err) {
665
+ (options.log ?? platformLog)(`文件下载失败: ${(err as Error).message}`);
666
+ }
667
+ }
668
+
669
+ if (item.video_item?.media) {
670
+ try {
671
+ const localPath = await downloadWechatVideo(item.video_item, message.message_id, options);
672
+ videoPaths.push(localPath);
673
+ messageLines.push(`[视频] ${localPath}`);
674
+ } catch (err) {
675
+ (options.log ?? platformLog)(`视频下载失败: ${(err as Error).message}`);
676
+ }
677
+ }
678
+ }
679
+ }
680
+
681
+ return {
682
+ imagePaths,
683
+ filePaths,
684
+ videoPaths,
685
+ messageLines,
686
+ };
687
+ }
688
+
689
+ export async function _downloadWechatMediaAttachmentsForTest(
690
+ message: WeixinMessage,
691
+ options: WechatMediaDownloadOptions,
692
+ ): Promise<WechatDownloadedMediaAttachments> {
693
+ return downloadWechatMediaAttachments(message, options);
694
+ }
695
+
542
696
  async function handleWechatMessage(
543
697
  message: WeixinMessage,
544
698
  handler: MessageHandler,
@@ -564,37 +718,23 @@ async function handleWechatMessage(
564
718
  const text = extractText(message).trim();
565
719
  const msgTimestamp = message.create_time_ms ?? Date.now();
566
720
 
567
- // 检测并下载图片
568
- const imagePaths: string[] = [];
569
- const items = message.item_list;
570
- if (items) {
571
- for (const item of items) {
572
- if (item.image_item?.media) {
573
- try {
574
- const localPath = await downloadWechatImage(item.image_item, message.message_id);
575
- imagePaths.push(localPath);
576
- } catch (err) {
577
- platformLog(`图片下载失败: ${(err as Error).message}`);
578
- }
579
- }
580
- }
581
- }
721
+ const mediaAttachments = await downloadWechatMediaAttachments(message);
582
722
 
583
- // 构建消息文本:文本内容 + 图片路径
723
+ // 构建消息文本:文本内容 + 媒体路径
584
724
  let fullText = text;
585
- if (imagePaths.length > 0) {
586
- const imageLines = imagePaths.map((p) => `[图片] ${p}`).join("\n");
587
- fullText = fullText ? `${fullText}\n${imageLines}` : imageLines;
725
+ if (mediaAttachments.messageLines.length > 0) {
726
+ const mediaLines = mediaAttachments.messageLines.join("\n");
727
+ fullText = fullText ? `${fullText}\n${mediaLines}` : mediaLines;
588
728
  }
589
729
 
590
- // 纯图片且无文字时跳过(避免空消息触发会话)
730
+ // 纯媒体且无可下载内容时跳过(避免空消息触发会话)
591
731
  if (!fullText.trim()) {
592
732
  platformLog(`跳过纯媒体消息(无文本): chatId=${chatId}`);
593
733
  return;
594
734
  }
595
735
 
596
736
  platformLog(
597
- `收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${imagePaths.length}`,
737
+ `收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${mediaAttachments.imagePaths.length} files=${mediaAttachments.filePaths.length} videos=${mediaAttachments.videoPaths.length}`,
598
738
  );
599
739
  appendChatLog(chatId, chatId, fullText);
600
740