chatccc 0.2.60 → 0.2.62

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.
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "feishu": {
3
3
  "appId": "",
4
- "appSecret": ""
4
+ "appSecret": "",
5
+ "domain": "feishu"
5
6
  },
6
7
  "platforms": {
7
8
  "feishu": { "enabled": true },
@@ -10,14 +11,14 @@
10
11
  "port": 18080,
11
12
  "gitTimeoutSeconds": 180,
12
13
  "allowInterrupt": false,
13
- "claude": {
14
- "enabled": false,
15
- "defaultAgent": true,
16
- "model": "claude-sonnet-4-6",
17
- "subagentModel": "",
18
- "effort": "",
19
- "apiKey": "",
20
- "baseUrl": ""
14
+ "claude": {
15
+ "enabled": false,
16
+ "defaultAgent": true,
17
+ "model": "",
18
+ "subagentModel": "",
19
+ "effort": "",
20
+ "apiKey": "",
21
+ "baseUrl": ""
21
22
  },
22
23
  "cursor": {
23
24
  "enabled": false,
@@ -30,6 +30,12 @@ function walkUpForConfig(startDir) {
30
30
  return result;
31
31
  }
32
32
 
33
+ function getBaseUrl(domain) {
34
+ return domain === "lark"
35
+ ? "https://open.larksuite.com/open-apis"
36
+ : "https://open.feishu.cn/open-apis";
37
+ }
38
+
33
39
  async function findConfig() {
34
40
  const paths = [
35
41
  join(homedir(), ".chatccc", "config.json"),
@@ -44,7 +50,7 @@ async function findConfig() {
44
50
  const appSecret = cfg.feishu?.appSecret || "";
45
51
  if (appId && appSecret) {
46
52
  console.error(`Using config: ${path}`);
47
- return { appId, appSecret };
53
+ return { appId, appSecret, baseUrl: getBaseUrl(cfg.feishu?.domain) };
48
54
  }
49
55
  } catch {
50
56
  // Try the next candidate.
@@ -53,8 +59,8 @@ async function findConfig() {
53
59
  throw new Error(`Could not find Feishu config. Tried: ${paths.slice(0, 3).join(", ")}...`);
54
60
  }
55
61
 
56
- async function getTenantAccessToken(appId, appSecret) {
57
- const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
62
+ async function getTenantAccessToken(appId, appSecret, baseUrl) {
63
+ const response = await fetch(`${baseUrl}/auth/v3/tenant_access_token/internal`, {
58
64
  method: "POST",
59
65
  headers: { "Content-Type": "application/json; charset=utf-8" },
60
66
  body: Buffer.from(JSON.stringify({ app_id: appId, app_secret: appSecret }), "utf8"),
@@ -66,10 +72,10 @@ async function getTenantAccessToken(appId, appSecret) {
66
72
  return data.tenant_access_token;
67
73
  }
68
74
 
69
- async function findMessageId(token, chatId, fileKey) {
75
+ async function findMessageId(token, baseUrl, chatId, fileKey) {
70
76
  let pageToken = "";
71
77
  for (let page = 0; page < 10; page++) {
72
- const url = new URL("https://open.feishu.cn/open-apis/im/v1/messages");
78
+ const url = new URL(`${baseUrl}/im/v1/messages`);
73
79
  url.searchParams.set("receive_id_type", "chat_id");
74
80
  url.searchParams.set("receive_id", chatId);
75
81
  url.searchParams.set("page_size", "50");
@@ -101,8 +107,8 @@ function safeFileName(name) {
101
107
  return (name || "download.bin").replace(/[\\/:*?"<>|]/g, "_");
102
108
  }
103
109
 
104
- async function downloadResource(token, messageId, fileKey, fileName) {
105
- const url = `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=file`;
110
+ async function downloadResource(token, baseUrl, messageId, fileKey, fileName) {
111
+ const url = `${baseUrl}/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=file`;
106
112
  console.error(`Downloading: ${url}`);
107
113
 
108
114
  const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
@@ -131,12 +137,12 @@ async function main() {
131
137
  process.exit(1);
132
138
  }
133
139
 
134
- const { appId, appSecret } = await findConfig();
135
- const token = await getTenantAccessToken(appId, appSecret);
140
+ const { appId, appSecret, baseUrl } = await findConfig();
141
+ const token = await getTenantAccessToken(appId, appSecret, baseUrl);
136
142
 
137
143
  let messageId = args["message-id"] || "";
138
144
  if (!messageId && args["chat-id"]) {
139
- messageId = await findMessageId(token, args["chat-id"], args["file-key"]);
145
+ messageId = await findMessageId(token, baseUrl, args["chat-id"], args["file-key"]);
140
146
  if (!messageId) {
141
147
  throw new Error(`No message found for file_key=${args["file-key"]}`);
142
148
  }
@@ -149,6 +155,7 @@ async function main() {
149
155
 
150
156
  const localPath = await downloadResource(
151
157
  token,
158
+ baseUrl,
152
159
  messageId,
153
160
  args["file-key"],
154
161
  args.name || "download.bin",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.60",
3
+ "version": "0.2.62",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,18 +1,19 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- import { buildHelpCard } from "../cards.ts";
4
- import {
5
- _flushPendingClawFinalTextForTest,
6
- _resetWechatClawStateForTest,
7
- createWechatAdapter,
8
- } from "../wechat-platform.ts";
9
-
10
- describe("createWechatAdapter", () => {
11
- beforeEach(() => {
12
- _resetWechatClawStateForTest();
13
- });
14
-
15
- it("degrades raw cards to plain text messages", async () => {
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { buildHelpCard } from "../cards.ts";
4
+ import {
5
+ _resetWechatClawStateForTest,
6
+ _setWxMinSendIntervalMsForTest,
7
+ createWechatAdapter,
8
+ } from "../wechat-platform.ts";
9
+
10
+ describe("createWechatAdapter", () => {
11
+ beforeEach(() => {
12
+ _resetWechatClawStateForTest();
13
+ _setWxMinSendIntervalMsForTest(0); // 测试中禁用发送间隔限制
14
+ });
15
+
16
+ it("degrades raw cards to plain text messages", async () => {
16
17
  const wire = {
17
18
  push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
18
19
  sendText: vi.fn(
@@ -38,7 +39,7 @@ describe("createWechatAdapter", () => {
38
39
  expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
39
40
  });
40
41
 
41
- it("reports unsent non-final messages after the claw limit", async () => {
42
+ it("appends claw suffix on 9th non-final message and blocks further non-final messages", async () => {
42
43
  const wire = {
43
44
  push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
44
45
  sendText: vi.fn(
@@ -52,48 +53,59 @@ describe("createWechatAdapter", () => {
52
53
  log,
53
54
  });
54
55
 
55
- for (let i = 0; i < 10; i++) {
56
+ // 前8条正常发送
57
+ for (let i = 0; i < 8; i++) {
56
58
  await expect(platform.sendText("wx-chat-limit", `chunk ${i}`)).resolves.toBe(true);
57
59
  }
58
60
 
59
- await expect(platform.sendText("wx-chat-limit", "chunk 10")).resolves.toBe(false);
61
+ // 第9条非最终消息:应附加 claw 后缀
62
+ await expect(platform.sendText("wx-chat-limit", "chunk 8")).resolves.toBe(true);
63
+
64
+ // 第10条起非最终消息被阻止
65
+ await expect(platform.sendText("wx-chat-limit", "chunk 9")).resolves.toBe(false);
66
+
67
+ expect(wire.push).toHaveBeenCalledTimes(9);
68
+ // 验证第9条附带了 claw 后缀
69
+ const ninthCall = wire.push.mock.calls[8];
70
+ expect(ninthCall[1]).toContain("chunk 8");
71
+ expect(ninthCall[1]).toContain("由于微信claw机制限制,不再发送过程,稍后把最终结果发送给你");
72
+ expect(log).toHaveBeenCalledWith(
73
+ "[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=10",
74
+ );
75
+ });
76
+
77
+ it("allows final messages through after the 9th message claw limit", async () => {
78
+ const wire = {
79
+ push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
80
+ sendText: vi.fn(
81
+ async (_chatId: string, _text: string, _contextToken?: string) =>
82
+ "msg-id",
83
+ ),
84
+ };
85
+ const log = vi.fn();
86
+ const platform = createWechatAdapter({
87
+ getWire: () => wire,
88
+ log,
89
+ });
90
+ const chatId = "wx-chat-final-allow";
91
+ const finalText = "done\n━━━ 回答结束 ━━━";
92
+
93
+ // 前8条正常发送
94
+ for (let i = 0; i < 8; i++) {
95
+ await expect(platform.sendText(chatId, `chunk ${i}`)).resolves.toBe(true);
96
+ }
97
+
98
+ // 第9条非最终消息带后缀
99
+ await expect(platform.sendText(chatId, "chunk 8")).resolves.toBe(true);
100
+
101
+ // 第10条:最终消息应允许发送(不被阻止也不被 queuing)
102
+ await expect(platform.sendText(chatId, finalText)).resolves.toBe(true);
103
+
60
104
  expect(wire.push).toHaveBeenCalledTimes(10);
105
+ const lastCall = wire.push.mock.calls[9];
106
+ expect(lastCall[1]).toBe(finalText);
61
107
  expect(log).toHaveBeenCalledWith(
62
- "[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=11",
63
- );
64
- });
65
-
66
- it("queues final messages after the claw limit until the user wakes the chat", async () => {
67
- const wire = {
68
- push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
69
- sendText: vi.fn(
70
- async (_chatId: string, _text: string, _contextToken?: string) =>
71
- "msg-id",
72
- ),
73
- };
74
- const log = vi.fn();
75
- const platform = createWechatAdapter({
76
- getWire: () => wire,
77
- log,
78
- });
79
- const chatId = "wx-chat-final-limit";
80
- const finalText = "done\n━━━ 回答结束 ━━━";
81
-
82
- for (let i = 0; i < 10; i++) {
83
- await expect(platform.sendText(chatId, `chunk ${i}`)).resolves.toBe(true);
84
- }
85
-
86
- await expect(platform.sendText(chatId, finalText)).resolves.toBe(true);
87
- expect(wire.push).toHaveBeenCalledTimes(10);
88
- expect(log).toHaveBeenCalledWith(
89
- `[WECHAT] final queued (claw limit): chatId=${chatId} count=11 len=${finalText.length}`,
90
- );
91
-
92
- await expect(_flushPendingClawFinalTextForTest(chatId, wire, log)).resolves.toBe(true);
93
- expect(wire.push).toHaveBeenCalledTimes(11);
94
- expect(wire.push).toHaveBeenLastCalledWith(chatId, finalText);
95
- expect(log).toHaveBeenCalledWith(
96
- `[WECHAT] pending final sent after claw wake: chatId=${chatId} len=${finalText.length}`,
97
- );
98
- });
99
- });
108
+ expect.stringContaining("[WECHAT] sendText OK"),
109
+ );
110
+ });
111
+ });
package/src/config.ts CHANGED
@@ -5,6 +5,8 @@ import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { appendFile, cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
7
7
 
8
+ import { Domain } from "@larksuiteoapi/node-sdk";
9
+
8
10
  import { printServiceDidNotStart } from "./exit-banner.ts";
9
11
  import { appendStartupTrace, setupFileLogging } from "./shared.ts";
10
12
  import {
@@ -90,9 +92,25 @@ export interface CodexConfig {
90
92
  effort: string;
91
93
  }
92
94
 
95
+ export type FeishuDomain = "feishu" | "lark";
96
+
93
97
  export interface FeishuConfig {
94
98
  appId: string;
95
99
  appSecret: string;
100
+ /** "feishu" (默认) → https://open.feishu.cn, "lark" → https://open.larksuite.com */
101
+ domain?: FeishuDomain;
102
+ }
103
+
104
+ /** 用户配置的 domain 字符串 → SDK Domain 枚举 */
105
+ export function feishuDomainToSdkDomain(domain?: FeishuDomain): Domain {
106
+ return domain === "lark" ? Domain.Lark : Domain.Feishu;
107
+ }
108
+
109
+ /** 用户配置的 domain 字符串 → REST API base URL */
110
+ export function feishuDomainToBaseUrl(domain?: FeishuDomain): string {
111
+ return domain === "lark"
112
+ ? "https://open.larksuite.com/open-apis"
113
+ : "https://open.feishu.cn/open-apis";
96
114
  }
97
115
 
98
116
  export interface PlatformConfig {
@@ -298,7 +316,7 @@ function autofillToolPathsAfterSampleCopy(configFile: string): void {
298
316
 
299
317
  function loadConfig(): AppConfig {
300
318
  const defaults: AppConfig = {
301
- feishu: { appId: "", appSecret: "" },
319
+ feishu: { appId: "", appSecret: "", domain: "feishu" },
302
320
  platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
303
321
  port: 18080,
304
322
  gitTimeoutSeconds: 180,
@@ -358,6 +376,8 @@ function loadConfig(): AppConfig {
358
376
  }
359
377
 
360
378
  const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
379
+ const feishuDomain: FeishuDomain =
380
+ (feishu as { domain?: unknown }).domain === "lark" ? "lark" : "feishu";
361
381
  const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
362
382
  const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
363
383
  const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
@@ -420,6 +440,7 @@ function loadConfig(): AppConfig {
420
440
  feishu: {
421
441
  appId: feishu.appId ?? "",
422
442
  appSecret: feishu.appSecret ?? "",
443
+ domain: feishuDomain,
423
444
  },
424
445
  platforms: {
425
446
  feishu: {
@@ -497,7 +518,7 @@ export let APP_SECRET = config.feishu.appSecret;
497
518
  export let FEISHU_ENABLED = config.platforms.feishu.enabled;
498
519
  export let ILINK_ENABLED = config.platforms.ilink.enabled;
499
520
  export let ILINK_REUSE_TOKEN_ON_START = config.platforms.ilink.reuseTokenOnStart ?? true;
500
- export const BASE_URL = "https://open.feishu.cn/open-apis";
521
+ export let BASE_URL = feishuDomainToBaseUrl(config.feishu.domain);
501
522
  export const CHATCCC_PORT = config.port;
502
523
 
503
524
  /** 与 CHATCCC_PORT 一致,供 --local 连接本机中继 */
@@ -576,6 +597,7 @@ export function applyLoadedConfig(next: AppConfig): void {
576
597
 
577
598
  APP_ID = next.feishu.appId;
578
599
  APP_SECRET = next.feishu.appSecret;
600
+ BASE_URL = feishuDomainToBaseUrl(next.feishu.domain);
579
601
  FEISHU_ENABLED = next.platforms.feishu.enabled;
580
602
  ILINK_ENABLED = next.platforms.ilink.enabled;
581
603
  ILINK_REUSE_TOKEN_ON_START = next.platforms.ilink.reuseTokenOnStart ?? true;
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
26
26
 
27
- import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
27
+ import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
28
28
  import WebSocket from "ws";
29
29
 
30
30
  import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
@@ -50,6 +50,8 @@ import {
50
50
  maskAppId,
51
51
  resolveDefaultAgentTool,
52
52
  toolDisplayName,
53
+ config,
54
+ feishuDomainToSdkDomain,
53
55
  ts,
54
56
  } from "./config.ts";
55
57
  import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.ts";
@@ -403,7 +405,7 @@ async function startBotServiceCore(): Promise<void> {
403
405
  console.error(` 接口: POST ${BASE_URL}/auth/v3/tenant_access_token/internal`);
404
406
  console.error(" 常见原因:");
405
407
  console.error(
406
- " - 本机网络无法访问 open.feishu.cn(可尝试:关闭系统/终端代理、检查防火墙;Windows 可管理员运行 netsh winsock reset 后重启)",
408
+ ` - 本机网络无法访问 ${new URL(BASE_URL).hostname}(可尝试:关闭系统/终端代理、检查防火墙;Windows 可管理员运行 netsh winsock reset 后重启)`,
407
409
  );
408
410
  console.error(" - App ID / App Secret 与开放平台「凭证与基础信息」不一致");
409
411
  console.error(" - 自建应用尚未创建/发布可用版本");
@@ -594,6 +596,7 @@ async function startBotServiceCore(): Promise<void> {
594
596
  const wsClient = new WSClient({
595
597
  appId: APP_ID,
596
598
  appSecret: APP_SECRET,
599
+ domain: feishuDomainToSdkDomain(config.feishu.domain),
597
600
  onReady: async () => {
598
601
  await rebuildBindingsFromRegistry().catch((err) =>
599
602
  console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
@@ -21,6 +21,7 @@ import {
21
21
  setDefaultCwd,
22
22
  getRecentDirs,
23
23
  addRecentDir,
24
+ resolveDefaultAgentTool,
24
25
  sessionPrefixForTool,
25
26
  toolDisplayName,
26
27
  ts,
@@ -240,7 +241,7 @@ export async function handleCommand(
240
241
 
241
242
  if (textLower === "/new" || textLower.startsWith("/new ")) {
242
243
  const toolArg = text.slice(5).trim().toLowerCase();
243
- const tool = toolArg || "claude";
244
+ const tool = toolArg || resolveDefaultAgentTool();
244
245
  logTrace(tid, "BRANCH", { cmd: "/new", tool });
245
246
  const validTools = ["claude", "cursor", "codex"];
246
247
  if (!validTools.includes(tool)) {
package/src/session.ts CHANGED
@@ -30,6 +30,13 @@ import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
30
30
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
31
31
  import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
32
32
  import type { PlatformAdapter } from "./platform-adapter.ts";
33
+
34
+ // 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
35
+ function compressWechatDisplayText(text: string): string {
36
+ const lines = text.split("\n");
37
+ if (lines.length <= 10) return text;
38
+ return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
39
+ }
33
40
  import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
34
41
  import {
35
42
  bindChatToSession,
@@ -1015,7 +1022,7 @@ export function ensureDisplayLoop(sessionId: string): void {
1015
1022
 
1016
1023
  d.cardBusy = true;
1017
1024
  try {
1018
- const ok = await p.sendText(chatId, delta);
1025
+ const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
1019
1026
  if (ok) {
1020
1027
  d.lastSentAccLen = state.accumulatedContent.length;
1021
1028
  d.lastSentFinalReply = state.finalReply;
package/src/web-ui.ts CHANGED
@@ -342,6 +342,9 @@ 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;
345
348
  } else if (key === "CHATCCC_FEISHU_ENABLED") {
346
349
  result.platforms = result.platforms || {};
347
350
  (result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
@@ -639,6 +642,14 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
639
642
  <input type="password" id="field-CHATCCC_APP_SECRET" placeholder="...">
640
643
  <div class="hint">飞书开放平台「凭证与基础信息」→ App Secret</div>
641
644
  </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>
642
653
  </div>
643
654
  </div>
644
655
 
@@ -826,6 +837,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
826
837
  </div>
827
838
  <div class="config-row"><span class="key">App ID</span><span class="val" id="cfg-APP_ID">-</span></div>
828
839
  <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>
829
841
  <button class="btn btn-outline" style="margin-top:8px" onclick="editSection('feishu')">编辑</button>
830
842
  </div>
831
843
  </details>
@@ -926,7 +938,7 @@ const AGENT_FIELDS = {
926
938
  cursor: ['CHATCCC_CURSOR_PATH','CHATCCC_CURSOR_MODEL'],
927
939
  codex: ['CHATCCC_CODEX_PATH','CHATCCC_CODEX_MODEL','CHATCCC_CODEX_EFFORT']
928
940
  };
929
- const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
941
+ const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET','CHATCCC_FEISHU_DOMAIN'];
930
942
 
931
943
  // 当前选中的 Claude API 模式("official" / "thirdparty")
932
944
  // Wizard / Dashboard 都通过这个变量驱动 UI 显隐和提交时的 mode 字段
@@ -1197,6 +1209,7 @@ function renderStep1() {
1197
1209
  var f = c.feishu || {};
1198
1210
  prefillNested('field-CHATCCC_APP_ID', f.appId);
1199
1211
  prefillNested('field-CHATCCC_APP_SECRET', f.appSecret);
1212
+ prefillNested('field-CHATCCC_FEISHU_DOMAIN', f.domain || 'feishu');
1200
1213
  // 平台开关:按已有 config 回填;首次配置(无飞书凭证)时默认关闭飞书、开启微信
1201
1214
  var hasExistingCreds = Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
1202
1215
  var feishuEnabled = hasExistingCreds
@@ -1349,6 +1362,7 @@ function renderStep3() {
1349
1362
  if (state.platformsEnabled.feishu) {
1350
1363
  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>');
1351
1364
  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>');
1352
1366
  }
1353
1367
 
1354
1368
  lines.push('<h3 style="margin:16px 0 8px">微信 iLink</h3>');
@@ -1534,6 +1548,7 @@ function updateDashboardUI() {
1534
1548
  state.platformsEnabled.feishu = feishuEnabled;
1535
1549
  document.getElementById('cfg-APP_ID').textContent = c.feishu && c.feishu.appId ? c.feishu.appId.slice(0,8) + '...' + c.feishu.appId.slice(-4) : '-';
1536
1550
  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)';
1537
1552
 
1538
1553
  // 只显示已启用的 Agent 卡片(按 enabled 字段;缺省时退回到"任一字段非空"兼容旧 config)
1539
1554
  var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
@@ -1636,7 +1651,7 @@ function editSection(section) {
1636
1651
 
1637
1652
  var html = '';
1638
1653
  var labelMap = {
1639
- 'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret',
1654
+ 'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret', 'CHATCCC_FEISHU_DOMAIN': '平台域名',
1640
1655
  'CLAUDE_API_KEY': 'API Key', 'CLAUDE_BASE_URL': 'Base URL',
1641
1656
  'CHATCCC_ANTHROPIC_MODEL': '模型', 'CHATCCC_ANTHROPIC_SUBAGENT_MODEL': 'Subagent 模型', 'CHATCCC_ANTHROPIC_EFFORT': 'Effort',
1642
1657
  'CHATCCC_CURSOR_PATH': 'CLI 路径', 'CHATCCC_CURSOR_MODEL': '模型',
@@ -1673,6 +1688,7 @@ function editSection(section) {
1673
1688
  if (section === 'feishu') {
1674
1689
  if (key === 'CHATCCC_APP_ID' && state.config.feishu) val = state.config.feishu.appId || '';
1675
1690
  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';
1676
1692
  } else if (section === 'claude' && state.config.claude) {
1677
1693
  if (key === 'CLAUDE_API_KEY') val = state.config.claude.apiKey || '';
1678
1694
  else if (key === 'CLAUDE_BASE_URL') val = state.config.claude.baseUrl || '';
@@ -1707,7 +1723,14 @@ function editSection(section) {
1707
1723
  var groupClass = 'form-group' + (isClaudeSubagentField && claudeApiMode !== 'thirdparty' ? ' hidden' : '');
1708
1724
  var subagentAttr = isClaudeSubagentField ? ' data-claude-subagent-field="1"' : '';
1709
1725
  html += '<div class="' + groupClass + '"' + subagentAttr + '><label>' + (labelMap[key] || key) + '</label>';
1710
- html += '<input type="' + (isSecret ? 'password' : 'text') + '" id="edit-' + key + '" value="' + String(val).replace(/"/g,'&quot;') + '">';
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
+ }
1711
1734
  html += '</div>';
1712
1735
  });
1713
1736
 
@@ -66,14 +66,20 @@ export function getIlinkWire(): OpenIlinkWire | null {
66
66
  const contextTokenMap = new Map<string, string>();
67
67
  /** chatId → 用户未回复时已连发消息数 */
68
68
  const consecutiveSendCount = new Map<string, number>();
69
- /** chatId → claw 限制后等待用户唤醒再补发的最终消息 */
70
- const pendingClawFinalText = new Map<string, string>();
69
+ /** chatId → 上一次 sendText 成功的时间戳 */
70
+ const lastSendTimeMap = new Map<string, number>();
71
71
  const textCardMap = new Map<string, { chatId?: string; text: string; lastSentText: string; lastSentAt: number }>();
72
72
  let textCardSeq = 0;
73
73
  let platformLog: (msg: string) => void = () => {};
74
74
 
75
75
  const TEXT_CARD_UPDATE_INTERVAL_MS = 30_000;
76
76
 
77
+ /** 微信连发间隔阈值:第5条起限制 ≥ 此值(默认10秒),测试可覆写 */
78
+ let wxMinSendIntervalMs = 10_000;
79
+ export function _setWxMinSendIntervalMsForTest(ms: number): void {
80
+ wxMinSendIntervalMs = ms;
81
+ }
82
+
77
83
  type WechatWireSender = Pick<OpenIlinkWire, "sendText" | "push">;
78
84
 
79
85
  export interface WechatAdapterOptions {
@@ -89,7 +95,7 @@ function isFinalReplyText(text: string): boolean {
89
95
  return text.includes("━━━ 回答结束 ━━━");
90
96
  }
91
97
 
92
- function compressGeneratingText(text: string): string {
98
+ export function compressGeneratingText(text: string): string {
93
99
  const lines = text.split("\n");
94
100
  if (lines.length <= 10) return text;
95
101
  return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
@@ -104,38 +110,10 @@ async function sendWechatTextRaw(wire: WechatWireSender, chatId: string, text: s
104
110
  }
105
111
  }
106
112
 
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
113
  export function _resetWechatClawStateForTest(): void {
128
114
  consecutiveSendCount.clear();
129
- pendingClawFinalText.clear();
130
115
  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);
116
+ lastSendTimeMap.clear();
139
117
  }
140
118
 
141
119
  // ---------------------------------------------------------------------------
@@ -206,29 +184,31 @@ export function createWechatAdapter(
206
184
 
207
185
  const isFinal = isFinalReplyText(text);
208
186
 
209
- // 第10条且非最终消息:附加 claw 限制提示
210
- if (count === 10 && !isFinal) {
211
- text = text + "\n━━ 后台工作中,由于微信claw机制限制,请唤醒我才能继续发送消息";
187
+ // 第9条且非最终消息:附加 claw 限制提示,此后禁止继续发送生成消息
188
+ if (count === 9 && !isFinal) {
189
+ text = text + "\n---由于微信claw机制限制,不再发送过程,稍后把最终结果发送给你---";
190
+ }
191
+
192
+ // 超过9条后不允许发送非最终消息(生成消息),最终结果消息仍然允许
193
+ if (count > 9 && !isFinal) {
194
+ log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
195
+ return false;
212
196
  }
213
197
 
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;
198
+ // 第5条起限制发送间隔 10 秒(前4条保持原有 3s 显示循环节奏)
199
+ if (count > 4) {
200
+ const lastSentAt = lastSendTimeMap.get(chatId) ?? 0;
201
+ const elapsed = Date.now() - lastSentAt;
202
+ if (elapsed < wxMinSendIntervalMs) {
203
+ await new Promise((r) => setTimeout(r, wxMinSendIntervalMs - elapsed));
223
204
  }
224
205
  }
225
206
 
226
207
  try {
227
- // 最后一步:非最终回复压缩行数(最多11行:头5 + ... + 尾5)
228
- const sendText = isFinal ? text : compressGeneratingText(text);
229
- await sendWechatTextRaw(wire, chatId, sendText);
230
- const preview = sendText.length > 60 ? sendText.slice(0, 60) + "..." : sendText;
231
- log(`[WECHAT] sendText OK: chatId=${chatId} len=${sendText.length} count=${count} text="${preview}"`);
208
+ await sendWechatTextRaw(wire, chatId, text);
209
+ lastSendTimeMap.set(chatId, Date.now());
210
+ const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
211
+ log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
232
212
  return true;
233
213
  } catch (err) {
234
214
  log(`[WECHAT] sendText failed: ${(err as Error).message}`);
@@ -621,12 +601,6 @@ async function handleWechatMessage(
621
601
  // 用户回复,重置 claw 连发计数
622
602
  consecutiveSendCount.set(chatId, 0);
623
603
 
624
- // 如果上一轮最终消息因 claw 被暂存,这次用户消息只作为唤醒使用。
625
- if (pendingClawFinalText.has(chatId)) {
626
- await flushPendingClawFinalText(chatId, ilinkWire, platformLog);
627
- return;
628
- }
629
-
630
604
  // WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
631
605
  // 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
632
606
  handler(fullText, chatId, chatId, msgTimestamp, "p2p").catch((err) => {