@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.
Files changed (2) hide show
  1. package/dist/main.mjs +368 -21
  2. 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$29 = larkLogger("preflight");
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$29.info("开始前置环境检查...");
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$29.info(` ${status(claude)}`);
82
- log$29.info(` ${status(larkCli)}`);
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$29.error(msg);
112
+ log$30.error(msg);
113
113
  throw new Error("前置环境检查未通过,缺少必要依赖");
114
114
  }
115
- log$29.info("前置环境检查通过 ✓");
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
- const isResuming = this.ccSessionExists(cwd, claudeSessionId);
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 (!data || typeof data !== "object") return null;
8737
- const feishuErr = data;
8738
- if (feishuErr.code !== LARK_ERROR.APP_SCOPE_MISSING) return null;
8739
- const msg = feishuErr.msg ?? "";
8740
- const grantUrl = extractPermissionGrantUrl(msg);
8741
- if (!grantUrl) return null;
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: feishuErr.code,
8744
- message: msg,
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-lark/larkpal",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "LarkPal - Lark/Feishu bot service",
5
5
  "type": "module",
6
6
  "main": "./dist/main.mjs",