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.
- package/config.sample.json +2 -3
- package/im-skills/feishu-skill/download-video.mjs +10 -17
- package/im-skills/wechat-file-skill/receive-send-file.md +39 -0
- package/im-skills/wechat-file-skill/send-file.mjs +84 -0
- package/im-skills/wechat-file-skill/skill.md +11 -0
- package/im-skills/{wechat-skill → wechat-image-skill}/receive-send-image.md +39 -39
- package/im-skills/{wechat-skill → wechat-image-skill}/send-image.mjs +84 -80
- package/im-skills/{wechat-skill → wechat-image-skill}/skill.md +11 -11
- package/im-skills/wechat-video-skill/receive-send-video.md +39 -0
- package/im-skills/wechat-video-skill/send-video.mjs +80 -0
- package/im-skills/wechat-video-skill/skill.md +11 -0
- package/package.json +59 -59
- package/src/__tests__/agent-platform-routing.test.ts +26 -0
- 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__/im-skills.test.ts +112 -3
- package/src/__tests__/orchestrator.test.ts +1 -1
- package/src/__tests__/privacy.test.ts +142 -142
- package/src/__tests__/simplify.test.ts +282 -282
- package/src/__tests__/web-ui.test.ts +23 -23
- package/src/__tests__/wechat-platform.test.ts +65 -0
- package/src/adapters/claude-adapter.ts +40 -40
- package/src/agent-file-rpc.ts +19 -4
- package/src/agent-image-rpc.ts +19 -4
- package/src/agent-platform-routing.ts +28 -0
- package/src/config.ts +2 -24
- package/src/im-skills.ts +9 -0
- package/src/index.ts +11 -6
- package/src/orchestrator.ts +2 -2
- package/src/privacy.ts +67 -67
- package/src/session.ts +21 -5
- package/src/simplify.ts +119 -119
- package/src/web-ui.ts +3 -26
- 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
|
|
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(
|
|
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
|
-
|
|
722
|
-
await
|
|
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'
|
|
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',
|
|
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
|
-
|
|
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,'"') + '">';
|
|
1733
|
-
}
|
|
1710
|
+
html += '<input type="' + (isSecret ? 'password' : 'text') + '" id="edit-' + key + '" value="' + String(val).replace(/"/g,'"') + '">';
|
|
1734
1711
|
html += '</div>';
|
|
1735
1712
|
});
|
|
1736
1713
|
|
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, 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 {
|
|
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
|
-
|
|
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
|
|
557
|
+
return fallback;
|
|
524
558
|
}
|
|
525
559
|
|
|
526
|
-
async function downloadWechatImage(
|
|
527
|
-
|
|
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
|
|
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
|
-
|
|
536
|
-
|
|
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 (
|
|
586
|
-
const
|
|
587
|
-
fullText = fullText ? `${fullText}\n${
|
|
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
|
|