@vibe-lark/larkpal 0.1.8 → 0.1.10
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/dist/main.mjs +368 -21
- package/package.json +1 -1
package/dist/main.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as LarkCliCredentialProvider } from "./lark-cli-provider-CdgwmqSz.mjs";
|
|
2
2
|
import { n as getLarkRuntime, r as setLarkRuntime, t as larkLogger } from "./lark-logger-D7_pEVQc.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import * as path$1 from "node:path";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -36,7 +36,7 @@ import { Readable } from "node:stream";
|
|
|
36
36
|
*
|
|
37
37
|
* 本模块在启动时检测它们是否已安装,缺失时给出明确的安装指引。
|
|
38
38
|
*/
|
|
39
|
-
const log$
|
|
39
|
+
const log$30 = larkLogger("preflight");
|
|
40
40
|
function detectCli(name) {
|
|
41
41
|
try {
|
|
42
42
|
const cliPath = execSync(`which ${name}`, {
|
|
@@ -74,12 +74,12 @@ function detectCli(name) {
|
|
|
74
74
|
* 缺失任一依赖时打印安装指引并抛出错误,阻止启动。
|
|
75
75
|
*/
|
|
76
76
|
function preflight() {
|
|
77
|
-
log$
|
|
77
|
+
log$30.info("开始前置环境检查...");
|
|
78
78
|
const claude = detectCli("claude");
|
|
79
79
|
const larkCli = detectCli("lark-cli");
|
|
80
80
|
const status = (dep) => dep.installed ? `✅ ${dep.name} (${dep.version ?? "unknown"}) → ${dep.path}` : `❌ ${dep.name} 未安装`;
|
|
81
|
-
log$
|
|
82
|
-
log$
|
|
81
|
+
log$30.info(` ${status(claude)}`);
|
|
82
|
+
log$30.info(` ${status(larkCli)}`);
|
|
83
83
|
const missing = [];
|
|
84
84
|
if (!claude.installed) missing.push([
|
|
85
85
|
"❌ 未找到 Claude Code (claude)",
|
|
@@ -109,10 +109,10 @@ function preflight() {
|
|
|
109
109
|
"",
|
|
110
110
|
"请安装缺失的依赖后重新启动 LarkPal。"
|
|
111
111
|
].join("\n");
|
|
112
|
-
log$
|
|
112
|
+
log$30.error(msg);
|
|
113
113
|
throw new Error("前置环境检查未通过,缺少必要依赖");
|
|
114
114
|
}
|
|
115
|
-
log$
|
|
115
|
+
log$30.info("前置环境检查通过 ✓");
|
|
116
116
|
return {
|
|
117
117
|
claude,
|
|
118
118
|
larkCli,
|
|
@@ -120,6 +120,100 @@ function preflight() {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
//#endregion
|
|
123
|
+
//#region src/config/ensure-lark-cli-config.ts
|
|
124
|
+
/**
|
|
125
|
+
* 确保 lark-cli 配置文件存在(同步环境变量凭证 → lark-cli 配置文件)
|
|
126
|
+
*
|
|
127
|
+
* 背景:
|
|
128
|
+
* CC 运行时会调用 `lark-cli config show` 来检测 lark-cli 是否已初始化。
|
|
129
|
+
* 在服务端部署中,凭证通过环境变量注入,lark-cli 配置文件不存在,
|
|
130
|
+
* 导致 CC 认为 lark-cli "未配置",影响 CC 内部对 lark-cli 功能的调用。
|
|
131
|
+
*
|
|
132
|
+
* 策略:
|
|
133
|
+
* 如果环境变量 LARK_APP_ID + LARK_APP_SECRET 已设置,且 lark-cli 配置文件
|
|
134
|
+
* 不存在,则主动生成配置文件,使 lark-cli 命令行也能识别为已配置状态。
|
|
135
|
+
*
|
|
136
|
+
* 同时生成新版 (~/.lark-cli/config.json) 和旧版 (~/.config/lark/config.json)
|
|
137
|
+
* 格式的配置文件,保证 lark-cli 无论通过哪种路径读取都能正常工作。
|
|
138
|
+
*/
|
|
139
|
+
const log$29 = larkLogger("config/ensure-lark-cli");
|
|
140
|
+
/** 新版 lark-cli 配置路径 */
|
|
141
|
+
const NEW_CONFIG_DIR = join(homedir(), ".lark-cli");
|
|
142
|
+
const NEW_CONFIG_PATH = join(NEW_CONFIG_DIR, "config.json");
|
|
143
|
+
/** 旧版 lark-cli 配置路径(lark-cli file backend 兼容路径) */
|
|
144
|
+
const LEGACY_CONFIG_DIR = join(homedir(), ".config", "lark");
|
|
145
|
+
const LEGACY_CONFIG_PATH = join(LEGACY_CONFIG_DIR, "config.json");
|
|
146
|
+
/**
|
|
147
|
+
* 确保 lark-cli 配置文件存在。
|
|
148
|
+
*
|
|
149
|
+
* 当环境变量提供了凭证但 lark-cli 配置文件缺失时,主动写入配置文件,
|
|
150
|
+
* 使 `lark-cli config show` 能正确返回已配置状态。
|
|
151
|
+
*/
|
|
152
|
+
function ensureLarkCliConfig() {
|
|
153
|
+
const appId = process.env.LARK_APP_ID;
|
|
154
|
+
const appSecret = process.env.LARK_APP_SECRET;
|
|
155
|
+
if (!appId || !appSecret) {
|
|
156
|
+
log$29.info("环境变量凭证未设置,跳过 lark-cli 配置同步");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const hasNewConfig = existsSync(NEW_CONFIG_PATH);
|
|
160
|
+
const hasLegacyConfig = existsSync(LEGACY_CONFIG_PATH);
|
|
161
|
+
if (hasNewConfig && hasLegacyConfig) {
|
|
162
|
+
log$29.info("lark-cli 配置文件已存在,跳过同步", {
|
|
163
|
+
newConfig: NEW_CONFIG_PATH,
|
|
164
|
+
legacyConfig: LEGACY_CONFIG_PATH
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
log$29.info("检测到环境变量凭证但 lark-cli 配置文件缺失,开始同步", {
|
|
169
|
+
appId,
|
|
170
|
+
hasNewConfig,
|
|
171
|
+
hasLegacyConfig
|
|
172
|
+
});
|
|
173
|
+
if (!hasNewConfig) try {
|
|
174
|
+
mkdirSync(NEW_CONFIG_DIR, {
|
|
175
|
+
recursive: true,
|
|
176
|
+
mode: 448
|
|
177
|
+
});
|
|
178
|
+
writeFileSync(NEW_CONFIG_PATH, JSON.stringify({ apps: [{
|
|
179
|
+
appId,
|
|
180
|
+
appSecret,
|
|
181
|
+
brand: "feishu",
|
|
182
|
+
lang: "zh"
|
|
183
|
+
}] }, null, 2), {
|
|
184
|
+
encoding: "utf-8",
|
|
185
|
+
mode: 384
|
|
186
|
+
});
|
|
187
|
+
log$29.info("已生成新版 lark-cli 配置文件", { path: NEW_CONFIG_PATH });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
log$29.warn("生成新版 lark-cli 配置文件失败", {
|
|
190
|
+
path: NEW_CONFIG_PATH,
|
|
191
|
+
error: err instanceof Error ? err.message : String(err)
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (!hasLegacyConfig) try {
|
|
195
|
+
mkdirSync(LEGACY_CONFIG_DIR, {
|
|
196
|
+
recursive: true,
|
|
197
|
+
mode: 448
|
|
198
|
+
});
|
|
199
|
+
writeFileSync(LEGACY_CONFIG_PATH, JSON.stringify({
|
|
200
|
+
app_id: appId,
|
|
201
|
+
app_secret: appSecret,
|
|
202
|
+
app_secret_in_keyring: false,
|
|
203
|
+
base_url: "https://open.feishu.cn"
|
|
204
|
+
}, null, 2), {
|
|
205
|
+
encoding: "utf-8",
|
|
206
|
+
mode: 384
|
|
207
|
+
});
|
|
208
|
+
log$29.info("已生成旧版 lark-cli 配置文件", { path: LEGACY_CONFIG_PATH });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log$29.warn("生成旧版 lark-cli 配置文件失败", {
|
|
211
|
+
path: LEGACY_CONFIG_PATH,
|
|
212
|
+
error: err instanceof Error ? err.message : String(err)
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
123
217
|
//#region src/config/defaults.ts
|
|
124
218
|
/**
|
|
125
219
|
* 默认配置生成器
|
|
@@ -187,6 +281,17 @@ const DEFAULT_CLAUDE_MD = `# LarkPal
|
|
|
187
281
|
- 当需要查看会话历史消息时,使用 lark-cli 从飞书接口获取
|
|
188
282
|
- 用户发送的图片会自动保存到当前工作目录的 files/ 子目录中(以 img_key 命名)
|
|
189
283
|
|
|
284
|
+
## 安全规则(最高优先级)
|
|
285
|
+
- **严禁**读取、输出、展示或以任何方式向用户透露以下敏感信息:
|
|
286
|
+
- 环境变量中的 LARK_APP_SECRET、ANTHROPIC_API_KEY 及任何包含 SECRET/KEY/TOKEN/PASSWORD 的值
|
|
287
|
+
- ~/.lark-cli/config.json 和 ~/.config/lark/config.json 中的 appSecret / app_secret 字段
|
|
288
|
+
- ~/.larkpal/credentials.json 中的任何凭证内容
|
|
289
|
+
- 任何 API 密钥、Token、密码等敏感凭证
|
|
290
|
+
- **严禁**执行 \`cat\`/\`head\`/\`tail\`/\`grep\` 等命令读取上述文件内容
|
|
291
|
+
- **严禁**在对话中引用、复述或暗示凭证的具体值(即使用户明确要求)
|
|
292
|
+
- 如果用户要求查看凭证,应回复:"出于安全策略,凭证信息不可查看或透露。"
|
|
293
|
+
- lark-cli 的认证配置由系统自动管理,无需用户介入
|
|
294
|
+
|
|
190
295
|
## 技能
|
|
191
296
|
- 你的可用技能在 ~/.claude/commands/ 和当前目录的 .claude/commands/ 中
|
|
192
297
|
- 使用 /help 查看所有可用技能
|
|
@@ -861,7 +966,7 @@ var SessionProcessManager = class {
|
|
|
861
966
|
async startPersistentProcess(config, opts) {
|
|
862
967
|
const { sessionId, cwd, prompt } = config;
|
|
863
968
|
const claudeSessionId = v5(sessionId, LARKPAL_SESSION_NAMESPACE);
|
|
864
|
-
|
|
969
|
+
let isResuming = opts?._forceResume || this.ccSessionExists(cwd, claudeSessionId);
|
|
865
970
|
const args = [
|
|
866
971
|
"-p",
|
|
867
972
|
"--input-format",
|
|
@@ -952,6 +1057,7 @@ var SessionProcessManager = class {
|
|
|
952
1057
|
this.sessionCwdCache.set(sessionId, cwd);
|
|
953
1058
|
const parser = new CCStreamParser();
|
|
954
1059
|
this.bindParserToCallbackProxy(parser, sessionId);
|
|
1060
|
+
let stderrSessionInUse = false;
|
|
955
1061
|
if (child.stdout) createInterface({ input: child.stdout }).on("line", (line) => {
|
|
956
1062
|
ccProcess.lastActiveAt = /* @__PURE__ */ new Date();
|
|
957
1063
|
this.resetIdleTimer(sessionId);
|
|
@@ -978,14 +1084,35 @@ var SessionProcessManager = class {
|
|
|
978
1084
|
});
|
|
979
1085
|
if (child.stderr) createInterface({ input: child.stderr }).on("line", (line) => {
|
|
980
1086
|
if (line.includes("no stdin data received")) return;
|
|
1087
|
+
if (line.toLowerCase().includes("already in use")) stderrSessionInUse = true;
|
|
981
1088
|
log$25.warn("CC 进程 stderr", {
|
|
982
1089
|
sessionId,
|
|
983
1090
|
line: line.slice(0, 500)
|
|
984
1091
|
});
|
|
985
1092
|
});
|
|
986
|
-
child.on("close", (code, signal) => {
|
|
1093
|
+
child.on("close", async (code, signal) => {
|
|
987
1094
|
const exitCode = code ?? -1;
|
|
988
1095
|
const exitSignal = signal ?? "none";
|
|
1096
|
+
if (exitCode === 1 && stderrSessionInUse && !isResuming) {
|
|
1097
|
+
log$25.info("Session ID 已被占用,准备使用 --resume 模式重试", {
|
|
1098
|
+
sessionId,
|
|
1099
|
+
claudeSessionId
|
|
1100
|
+
});
|
|
1101
|
+
this.processes.delete(sessionId);
|
|
1102
|
+
this.sessionCwdCache.delete(sessionId);
|
|
1103
|
+
try {
|
|
1104
|
+
await this.startPersistentProcess(config, {
|
|
1105
|
+
skipInitialMessage: opts?.skipInitialMessage,
|
|
1106
|
+
_forceResume: true
|
|
1107
|
+
});
|
|
1108
|
+
return;
|
|
1109
|
+
} catch (retryErr) {
|
|
1110
|
+
log$25.error("Session ID 重试失败", {
|
|
1111
|
+
sessionId,
|
|
1112
|
+
error: retryErr instanceof Error ? retryErr.message : String(retryErr)
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
989
1116
|
if (exitCode === 0) {
|
|
990
1117
|
log$25.info("CC 常驻进程正常退出", {
|
|
991
1118
|
sessionId,
|
|
@@ -4522,6 +4649,8 @@ const LARK_ERROR = {
|
|
|
4522
4649
|
APP_SCOPE_MISSING: 99991672,
|
|
4523
4650
|
/** 用户 token scope 不足 */
|
|
4524
4651
|
USER_SCOPE_INSUFFICIENT: 99991679,
|
|
4652
|
+
/** 无用户数据权限(如 contact:user.base:readonly 未开通) */
|
|
4653
|
+
NO_USER_AUTHORITY: 41050,
|
|
4525
4654
|
/** access_token 无效 */
|
|
4526
4655
|
TOKEN_INVALID: 99991668,
|
|
4527
4656
|
/** access_token 已过期 */
|
|
@@ -8375,6 +8504,127 @@ async function dispatchToCC(params) {
|
|
|
8375
8504
|
});
|
|
8376
8505
|
}
|
|
8377
8506
|
}
|
|
8507
|
+
const attachmentResources = ctx.resources.filter((r) => r.type === "file" || r.type === "audio" || r.type === "video" || r.type === "sticker");
|
|
8508
|
+
if (attachmentResources.length > 0) {
|
|
8509
|
+
const filesDir = path.join(route.cwd, "files");
|
|
8510
|
+
const { writeFile: fsWriteFile, mkdir: fsMkdir } = await import("fs/promises");
|
|
8511
|
+
await fsMkdir(filesDir, { recursive: true });
|
|
8512
|
+
let updatedTextPrompt = typeof prompt === "string" ? prompt : prompt[0].text;
|
|
8513
|
+
for (const res of attachmentResources) try {
|
|
8514
|
+
log$11.info("开始下载非图片附件", {
|
|
8515
|
+
type: res.type,
|
|
8516
|
+
fileKey: res.fileKey,
|
|
8517
|
+
fileName: res.fileName,
|
|
8518
|
+
duration: res.duration,
|
|
8519
|
+
messageId: ctx.messageId
|
|
8520
|
+
});
|
|
8521
|
+
const response = await LarkClient.fromAccount(account).sdk.im.messageResource.get({
|
|
8522
|
+
path: {
|
|
8523
|
+
message_id: ctx.messageId,
|
|
8524
|
+
file_key: res.fileKey
|
|
8525
|
+
},
|
|
8526
|
+
params: { type: "file" }
|
|
8527
|
+
});
|
|
8528
|
+
log$11.info("非图片附件 API 响应已收到", {
|
|
8529
|
+
type: res.type,
|
|
8530
|
+
fileKey: res.fileKey,
|
|
8531
|
+
responseType: typeof response,
|
|
8532
|
+
hasData: response != null
|
|
8533
|
+
});
|
|
8534
|
+
let buffer;
|
|
8535
|
+
if (response instanceof ArrayBuffer) buffer = Buffer.from(response);
|
|
8536
|
+
else if (Buffer.isBuffer(response)) buffer = response;
|
|
8537
|
+
else if (response && typeof response === "object") {
|
|
8538
|
+
const resp = response;
|
|
8539
|
+
if (resp.data instanceof ArrayBuffer) buffer = Buffer.from(resp.data);
|
|
8540
|
+
else if (Buffer.isBuffer(resp.data)) buffer = resp.data;
|
|
8541
|
+
else if (resp.writeFile) {
|
|
8542
|
+
const chunks = [];
|
|
8543
|
+
const readable = resp.getReadableStream();
|
|
8544
|
+
for await (const chunk of readable) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
8545
|
+
buffer = Buffer.concat(chunks);
|
|
8546
|
+
} else {
|
|
8547
|
+
log$11.warn("非图片附件下载返回未知格式,跳过", {
|
|
8548
|
+
type: res.type,
|
|
8549
|
+
fileKey: res.fileKey,
|
|
8550
|
+
responseType: typeof response
|
|
8551
|
+
});
|
|
8552
|
+
continue;
|
|
8553
|
+
}
|
|
8554
|
+
} else {
|
|
8555
|
+
log$11.warn("非图片附件下载返回空,跳过", {
|
|
8556
|
+
type: res.type,
|
|
8557
|
+
fileKey: res.fileKey
|
|
8558
|
+
});
|
|
8559
|
+
continue;
|
|
8560
|
+
}
|
|
8561
|
+
let savedFileName = "";
|
|
8562
|
+
switch (res.type) {
|
|
8563
|
+
case "file":
|
|
8564
|
+
savedFileName = res.fileName ? `${res.fileKey}_${res.fileName}` : `${res.fileKey}_file`;
|
|
8565
|
+
break;
|
|
8566
|
+
case "audio":
|
|
8567
|
+
savedFileName = `${res.fileKey}.ogg`;
|
|
8568
|
+
break;
|
|
8569
|
+
case "video":
|
|
8570
|
+
savedFileName = res.fileName ? `${res.fileKey}_${res.fileName}` : `${res.fileKey}_video`;
|
|
8571
|
+
break;
|
|
8572
|
+
case "sticker":
|
|
8573
|
+
savedFileName = `${res.fileKey}.png`;
|
|
8574
|
+
break;
|
|
8575
|
+
}
|
|
8576
|
+
const filePath = path.join(filesDir, savedFileName);
|
|
8577
|
+
await fsWriteFile(filePath, buffer);
|
|
8578
|
+
log$11.info("非图片附件已保存到工作目录", {
|
|
8579
|
+
type: res.type,
|
|
8580
|
+
fileKey: res.fileKey,
|
|
8581
|
+
savedFileName,
|
|
8582
|
+
path: filePath,
|
|
8583
|
+
sizeBytes: buffer.length
|
|
8584
|
+
});
|
|
8585
|
+
switch (res.type) {
|
|
8586
|
+
case "file": {
|
|
8587
|
+
const nameDisplay = res.fileName || savedFileName;
|
|
8588
|
+
const fileRegex = new RegExp(`<file\\s+key="${res.fileKey}"[^/]*/>`, "g");
|
|
8589
|
+
updatedTextPrompt = updatedTextPrompt.replace(fileRegex, `[文件: ${nameDisplay}] (已下载到 files/${savedFileName})`);
|
|
8590
|
+
break;
|
|
8591
|
+
}
|
|
8592
|
+
case "audio": {
|
|
8593
|
+
const audioRegex = new RegExp(`<audio\\s+key="${res.fileKey}"[^/]*/>`, "g");
|
|
8594
|
+
const durationStr = res.duration != null ? String(res.duration) : "未知";
|
|
8595
|
+
updatedTextPrompt = updatedTextPrompt.replace(audioRegex, `[语音: files/${savedFileName}] (时长: ${durationStr})`);
|
|
8596
|
+
break;
|
|
8597
|
+
}
|
|
8598
|
+
case "video": {
|
|
8599
|
+
const videoRegex = new RegExp(`<video\\s+key="${res.fileKey}"[^/]*/>`, "g");
|
|
8600
|
+
const videoName = res.fileName || "未知";
|
|
8601
|
+
const videoDuration = res.duration != null ? String(res.duration) : "未知";
|
|
8602
|
+
updatedTextPrompt = updatedTextPrompt.replace(videoRegex, `[视频: files/${savedFileName}] (文件名: ${videoName}, 时长: ${videoDuration})`);
|
|
8603
|
+
break;
|
|
8604
|
+
}
|
|
8605
|
+
case "sticker": {
|
|
8606
|
+
const stickerRegex = new RegExp(`<sticker\\s+key="${res.fileKey}"[^/]*/>`, "g");
|
|
8607
|
+
updatedTextPrompt = updatedTextPrompt.replace(stickerRegex, `[表情贴纸: files/${savedFileName}]`);
|
|
8608
|
+
break;
|
|
8609
|
+
}
|
|
8610
|
+
}
|
|
8611
|
+
} catch (err) {
|
|
8612
|
+
log$11.warn("非图片附件下载失败,保留原始占位符", {
|
|
8613
|
+
type: res.type,
|
|
8614
|
+
fileKey: res.fileKey,
|
|
8615
|
+
error: err instanceof Error ? err.message : String(err)
|
|
8616
|
+
});
|
|
8617
|
+
}
|
|
8618
|
+
if (typeof prompt === "string") prompt = updatedTextPrompt;
|
|
8619
|
+
else {
|
|
8620
|
+
const textBlock = prompt.find((b) => b.type === "text");
|
|
8621
|
+
if (textBlock) textBlock.text = updatedTextPrompt;
|
|
8622
|
+
}
|
|
8623
|
+
log$11.info("非图片附件处理完成", {
|
|
8624
|
+
totalAttachments: attachmentResources.length,
|
|
8625
|
+
promptLength: typeof prompt === "string" ? prompt.length : prompt.find((b) => b.type === "text")?.text?.length
|
|
8626
|
+
});
|
|
8627
|
+
}
|
|
8378
8628
|
log$11.info("消息格式化完成", {
|
|
8379
8629
|
promptLength: typeof prompt === "string" ? prompt.length : prompt.length,
|
|
8380
8630
|
isMultimodal: Array.isArray(prompt),
|
|
@@ -8730,21 +8980,55 @@ async function dispatchTeammateEval(params) {
|
|
|
8730
8980
|
* Extracted from bot.ts: PermissionError type, extractPermissionError,
|
|
8731
8981
|
* PERMISSION_ERROR_COOLDOWN_MS, permissionErrorNotifiedAt.
|
|
8732
8982
|
*/
|
|
8983
|
+
/** 所有表示权限不足的飞书 API 错误码 */
|
|
8984
|
+
const PERMISSION_ERROR_CODES = new Set([LARK_ERROR.APP_SCOPE_MISSING, LARK_ERROR.NO_USER_AUTHORITY]);
|
|
8985
|
+
/**
|
|
8986
|
+
* 从飞书 API 错误对象中提取权限错误信息。
|
|
8987
|
+
*
|
|
8988
|
+
* 支持两种错误结构:
|
|
8989
|
+
* 1. Axios 风格:err.response.data.{code, msg}(如 99991672)
|
|
8990
|
+
* 2. Lark SDK 直接抛出:err.{code, msg}(如 41050)
|
|
8991
|
+
*/
|
|
8733
8992
|
function extractPermissionError(err) {
|
|
8734
8993
|
if (!err || typeof err !== "object") return null;
|
|
8735
8994
|
const data = err.response?.data;
|
|
8736
|
-
if (
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8995
|
+
if (data && typeof data === "object") {
|
|
8996
|
+
const feishuErr = data;
|
|
8997
|
+
const result = matchPermissionError(feishuErr.code, feishuErr.msg);
|
|
8998
|
+
if (result) return result;
|
|
8999
|
+
}
|
|
9000
|
+
const directErr = err;
|
|
9001
|
+
const directCode = typeof directErr.code === "number" ? directErr.code : void 0;
|
|
9002
|
+
const directMsg = directErr.msg ?? directErr.message ?? "";
|
|
9003
|
+
if (directCode !== void 0) {
|
|
9004
|
+
const result = matchPermissionError(directCode, directMsg);
|
|
9005
|
+
if (result) return result;
|
|
9006
|
+
}
|
|
9007
|
+
return null;
|
|
9008
|
+
}
|
|
9009
|
+
/**
|
|
9010
|
+
* 根据错误码和错误消息,匹配并构造 PermissionError。
|
|
9011
|
+
*/
|
|
9012
|
+
function matchPermissionError(code, msg) {
|
|
9013
|
+
if (code === void 0 || !PERMISSION_ERROR_CODES.has(code)) return null;
|
|
9014
|
+
const message = msg ?? "";
|
|
8742
9015
|
return {
|
|
8743
|
-
code
|
|
8744
|
-
message
|
|
8745
|
-
grantUrl
|
|
9016
|
+
code,
|
|
9017
|
+
message,
|
|
9018
|
+
grantUrl: extractPermissionGrantUrl(message) || void 0,
|
|
9019
|
+
scopes: extractScopesFromMessage(message)
|
|
8746
9020
|
};
|
|
8747
9021
|
}
|
|
9022
|
+
/**
|
|
9023
|
+
* 从错误消息中提取权限 scope 列表。
|
|
9024
|
+
* 飞书错误消息中常见格式:[scope1,scope2,...] 或 scope: xxx
|
|
9025
|
+
*/
|
|
9026
|
+
function extractScopesFromMessage(msg) {
|
|
9027
|
+
const bracketMatch = msg.match(/\[([^\]]+)\]/);
|
|
9028
|
+
if (bracketMatch?.[1]) return bracketMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
9029
|
+
const scopeMatch = msg.match(/(?:scope[s]?[=:]\s*)([a-z][a-z0-9_.:-]+(?:,\s*[a-z][a-z0-9_.:-]+)*)/i);
|
|
9030
|
+
if (scopeMatch?.[1]) return scopeMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
9031
|
+
}
|
|
8748
9032
|
//#endregion
|
|
8749
9033
|
//#region src/messaging/inbound/user-name-cache.ts
|
|
8750
9034
|
/** Max user_ids per API call (Feishu limit). */
|
|
@@ -11059,7 +11343,8 @@ const DEFAULT_CONFIG = {
|
|
|
11059
11343
|
globalDefault: true,
|
|
11060
11344
|
groupOverrides: {},
|
|
11061
11345
|
bufferSize: 5,
|
|
11062
|
-
bufferTimeoutSec: 30
|
|
11346
|
+
bufferTimeoutSec: 30,
|
|
11347
|
+
maxHourlyInterventions: 10
|
|
11063
11348
|
};
|
|
11064
11349
|
var TeammateConfig = class {
|
|
11065
11350
|
data;
|
|
@@ -11130,6 +11415,9 @@ var TeammateConfig = class {
|
|
|
11130
11415
|
getBufferTimeoutSec() {
|
|
11131
11416
|
return this.data.bufferTimeoutSec;
|
|
11132
11417
|
}
|
|
11418
|
+
getMaxHourlyInterventions() {
|
|
11419
|
+
return this.data.maxHourlyInterventions;
|
|
11420
|
+
}
|
|
11133
11421
|
/** 获取完整配置数据(用于 API 展示) */
|
|
11134
11422
|
getData() {
|
|
11135
11423
|
return this.data;
|
|
@@ -11154,18 +11442,23 @@ const log$1 = larkLogger("messaging/teammate-buffer");
|
|
|
11154
11442
|
var TeammateBuffer = class {
|
|
11155
11443
|
/** 每群独立缓冲区 */
|
|
11156
11444
|
buffers = /* @__PURE__ */ new Map();
|
|
11445
|
+
/** 防失控兜底:每群每小时主动发言计数器 */
|
|
11446
|
+
hourlyCounters = /* @__PURE__ */ new Map();
|
|
11157
11447
|
/** 触发评估时的回调 */
|
|
11158
11448
|
evalCallback;
|
|
11159
11449
|
/** 配置参数 */
|
|
11160
11450
|
bufferSize;
|
|
11161
11451
|
bufferTimeoutMs;
|
|
11452
|
+
maxHourlyInterventions;
|
|
11162
11453
|
constructor(opts) {
|
|
11163
11454
|
this.evalCallback = opts.onEval;
|
|
11164
11455
|
this.bufferSize = opts.bufferSize ?? 5;
|
|
11165
11456
|
this.bufferTimeoutMs = (opts.bufferTimeoutSec ?? 30) * 1e3;
|
|
11457
|
+
this.maxHourlyInterventions = opts.maxHourlyInterventions ?? 10;
|
|
11166
11458
|
log$1.info("TeammateBuffer 初始化", {
|
|
11167
11459
|
bufferSize: this.bufferSize,
|
|
11168
|
-
bufferTimeoutMs: this.bufferTimeoutMs
|
|
11460
|
+
bufferTimeoutMs: this.bufferTimeoutMs,
|
|
11461
|
+
maxHourlyInterventions: this.maxHourlyInterventions
|
|
11169
11462
|
});
|
|
11170
11463
|
}
|
|
11171
11464
|
/**
|
|
@@ -11201,11 +11494,51 @@ var TeammateBuffer = class {
|
|
|
11201
11494
|
});
|
|
11202
11495
|
buf.evaluating = null;
|
|
11203
11496
|
buf.isEvaluating = false;
|
|
11497
|
+
if (replied) this.incrementHourlyCounter(chatId);
|
|
11204
11498
|
if (buf.active.length > 0) {
|
|
11205
11499
|
if (buf.active.length >= this.bufferSize) this.tryTrigger(chatId, buf);
|
|
11206
11500
|
else if (!buf.timer) this.startTimer(chatId, buf);
|
|
11207
11501
|
}
|
|
11208
11502
|
}
|
|
11503
|
+
/**
|
|
11504
|
+
* 检查指定群聊是否触发了防失控限流。
|
|
11505
|
+
* 如果当前小时窗口已达到上限,返回 true。
|
|
11506
|
+
*/
|
|
11507
|
+
isRateLimited(chatId) {
|
|
11508
|
+
const counter = this.hourlyCounters.get(chatId);
|
|
11509
|
+
if (!counter) return false;
|
|
11510
|
+
if (Date.now() - counter.windowStart >= 3600 * 1e3) {
|
|
11511
|
+
this.hourlyCounters.delete(chatId);
|
|
11512
|
+
log$1.info("teammate 防失控计数器已重置(时间窗口过期)", { chatId });
|
|
11513
|
+
return false;
|
|
11514
|
+
}
|
|
11515
|
+
return counter.count >= this.maxHourlyInterventions;
|
|
11516
|
+
}
|
|
11517
|
+
/**
|
|
11518
|
+
* 增加指定群聊的每小时主动发言计数。
|
|
11519
|
+
*/
|
|
11520
|
+
incrementHourlyCounter(chatId) {
|
|
11521
|
+
const now = Date.now();
|
|
11522
|
+
const oneHour = 3600 * 1e3;
|
|
11523
|
+
const counter = this.hourlyCounters.get(chatId);
|
|
11524
|
+
if (!counter || now - counter.windowStart >= oneHour) {
|
|
11525
|
+
this.hourlyCounters.set(chatId, {
|
|
11526
|
+
count: 1,
|
|
11527
|
+
windowStart: now
|
|
11528
|
+
});
|
|
11529
|
+
log$1.info("teammate 防失控计数器开始新窗口", {
|
|
11530
|
+
chatId,
|
|
11531
|
+
count: 1
|
|
11532
|
+
});
|
|
11533
|
+
} else {
|
|
11534
|
+
counter.count++;
|
|
11535
|
+
log$1.info("teammate 防失控计数器更新", {
|
|
11536
|
+
chatId,
|
|
11537
|
+
count: counter.count,
|
|
11538
|
+
limit: this.maxHourlyInterventions
|
|
11539
|
+
});
|
|
11540
|
+
}
|
|
11541
|
+
}
|
|
11209
11542
|
/** 清理所有缓冲区和定时器(优雅退出时调用) */
|
|
11210
11543
|
dispose() {
|
|
11211
11544
|
for (const [chatId, buf] of this.buffers) {
|
|
@@ -11249,6 +11582,18 @@ var TeammateBuffer = class {
|
|
|
11249
11582
|
return;
|
|
11250
11583
|
}
|
|
11251
11584
|
if (buf.active.length === 0) return;
|
|
11585
|
+
if (this.isRateLimited(chatId)) {
|
|
11586
|
+
log$1.warn("teammate 防失控兜底生效, 该群本小时已达上限", {
|
|
11587
|
+
chatId,
|
|
11588
|
+
limit: this.maxHourlyInterventions
|
|
11589
|
+
});
|
|
11590
|
+
if (buf.timer) {
|
|
11591
|
+
clearTimeout(buf.timer);
|
|
11592
|
+
buf.timer = null;
|
|
11593
|
+
}
|
|
11594
|
+
buf.active = [];
|
|
11595
|
+
return;
|
|
11596
|
+
}
|
|
11252
11597
|
if (buf.timer) {
|
|
11253
11598
|
clearTimeout(buf.timer);
|
|
11254
11599
|
buf.timer = null;
|
|
@@ -11725,6 +12070,7 @@ async function main() {
|
|
|
11725
12070
|
}
|
|
11726
12071
|
} catch {}
|
|
11727
12072
|
preflight();
|
|
12073
|
+
ensureLarkCliConfig();
|
|
11728
12074
|
const credentialProvider = new LarkCliCredentialProvider();
|
|
11729
12075
|
const appId = credentialProvider.getAppId();
|
|
11730
12076
|
logger.info("凭证加载完成", { appId });
|
|
@@ -11828,7 +12174,8 @@ async function main() {
|
|
|
11828
12174
|
});
|
|
11829
12175
|
},
|
|
11830
12176
|
bufferSize: teammateConfig.getBufferSize(),
|
|
11831
|
-
bufferTimeoutSec: teammateConfig.getBufferTimeoutSec()
|
|
12177
|
+
bufferTimeoutSec: teammateConfig.getBufferTimeoutSec(),
|
|
12178
|
+
maxHourlyInterventions: teammateConfig.getMaxHourlyInterventions()
|
|
11832
12179
|
});
|
|
11833
12180
|
logger.info("TeammateBuffer 初始化完成");
|
|
11834
12181
|
const wsAbortController = new AbortController();
|