@modelzen/feishu-codex-bridge 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -30,7 +30,8 @@
30
30
  - **流式卡片**:推理 / 命令 / 文件改动 / 结果实时刷新到一张可折叠卡片。
31
31
  - **免 @ 对话**:项目群话题内可直接说话、不必每次 @(可逐群开关)。
32
32
  - **文档评论回复(可选)**:在飞书云文档(doc/docx/sheet/file,含知识库 wiki)的评论里 **@机器人**,它会读评论、跑 Codex、把答案回到同一条评论线程里;每篇文档一条连续会话。需额外开通文档评论权限并订阅评论事件(见下方配置)。
33
- - **私聊控制台**:私聊机器人弹交互菜单 —— 新建项目、项目列表、设置、诊断、重连。
33
+ - **私聊控制台**:私聊机器人弹交互菜单 —— 新建项目、项目列表、设置、用量、诊断、重连。
34
+ - **📊 Codex 用量**:5 小时 / 7 天限额进度(剩余 % + 重置时间)、lifetime tokens、连续使用天数、GitHub 风格每日用量热力图;一键生成**战绩分享卡**,可原生转发给任何人或群(数据来自 Codex 个人资料页同款接口,需 ChatGPT 登录)。
34
35
  - **稳定隔离**:每会话独立 app-server 进程;卡死有 watchdog(默认 120s)→ 终止 → 回收,异常不波及其他群。
35
36
  - **本地加密密钥库**:飞书应用密钥用 AES-256-GCM 存在 `~/.feishu-codex-bridge/`,不入仓库、不进环境变量。
36
37
  - **跨平台常驻**:macOS / Windows / Linux·WSL 均可注册成后台服务、开机或登录自启(分别走 launchd / 登录自启免管理员 / systemd)。注:跨平台指进程运行与后台自启;「项目内只读/读写」隐私沙箱仅 macOS / 原生 Windows 可强制(见[安全须知](#-安全须知))。
@@ -164,9 +165,11 @@ feishu-codex-bridge bot rm <名> # 移除一个机器人配置
164
165
  - **💬 单会话群**:整群就是**一条连续会话**(全程**免 @**、消息按序排队、无 `/resume`)。适合**个人单线深入**、像私聊一样直接聊。
165
166
  - **干活**:在项目群里 **@机器人** 描述需求;机器人在该群绑定的目录里跑 Codex,流式卡片回结果。
166
167
  - **话题 = 会话**:对某条消息开话题后,话题内可**免 @** 连续对话,是一条连贯的 Codex 会话。
168
+ - **发图 / 发附件**:直接在消息里**发图片**,Codex 能看到(多模态读图);**发文件附件**(日志 / PDF / 代码等),桥会把它下载到本地并把**绝对路径**告诉 Codex,让它用工具直接打开分析。⚠️ 附件落在桥的全局临时目录(`~/.feishu-codex-bridge/inbound`,1h 后自动清),**只有「完全访问」档**能读到——「项目内只读 / 读写」档的沙箱把读取锁在项目目录内,读不到该目录。单文件上限 50MB、单条消息最多 9 个;合并转发里的附件飞书官方不支持取,故不支持。
167
169
  - **文档评论 @机器人**:在飞书文档评论里 @ 它就回(前提:已开通文档评论权限 + 订阅 `drive.notice.comment_add_v1`,且机器人对该文档有访问权限)。只支持 doc/docx/sheet/file;评论框不渲染 markdown,回复为纯文本,超长会截断。
168
170
  - **终止**:卡片上的 **⏹** 随时终止当前轮;卡死超过 watchdog 阈值(默认 120s)自动中止并回收进程。
169
- - **私聊控制台**:项目列表、设置(模型 / 推理强度 / 免 @ / watchdog / 管理员)、诊断、重连,全在私聊菜单里。
171
+ - **私聊控制台**:项目列表、设置(模型 / 推理强度 / 免 @ / watchdog / 管理员)、用量、诊断、重连,全在私聊菜单里。
172
+ - **📊 用量**:点「用量」看 5h/7d 限额(剩余 % + 重置时间)与 Codex 个人统计(lifetime tokens / streak / 每日热力图);点「📤 生成分享卡」得到一张可转发的战绩卡——长按(手机)或右键(电脑)即可转发,数据定格在生成时刻。
170
173
 
171
174
  ---
172
175
 
package/dist/cli.js CHANGED
@@ -68,7 +68,10 @@ var paths = {
68
68
  * passes lark-cli's AssertSecurePath audit.
69
69
  */
70
70
  secretsGetterScript: join(appDir, "secrets-getter"),
71
- mediaDir: join(appDir, "media")
71
+ mediaDir: join(appDir, "media"),
72
+ /** Inbound file attachments downloaded from chat, handed to codex by absolute
73
+ * path (codex has no native file input). TTL-pruned like {@link mediaDir}. */
74
+ inboundDir: join(appDir, "inbound")
72
75
  };
73
76
 
74
77
  // src/config/bots.ts
@@ -2072,6 +2075,7 @@ var RunRender = class {
2072
2075
  // src/card/cards.ts
2073
2076
  function card(elements, opts = {}) {
2074
2077
  const config = { update_multi: true };
2078
+ if (opts.forward === false) config.enable_forward = false;
2075
2079
  if (opts.streaming) {
2076
2080
  config.streaming_mode = true;
2077
2081
  config.streaming_config = {
@@ -3268,6 +3272,12 @@ var DM = {
3268
3272
  reconnect: "dm.reconnect",
3269
3273
  update: "dm.update",
3270
3274
  updateDo: "dm.update.do",
3275
+ // 📊 Codex 用量(限额 + 个人资料统计 + 热力图);share 打开内容选择卡,
3276
+ // shareDo 按所选区块生成可转发的分享卡
3277
+ usage: "dm.usage",
3278
+ usageRefresh: "dm.usage.refresh",
3279
+ usageShare: "dm.usage.share",
3280
+ usageShareDo: "dm.usage.share.do",
3271
3281
  rmConfirm: "dm.rmConfirm",
3272
3282
  rmDo: "dm.rmDo",
3273
3283
  rmCancel: "dm.rmCancel",
@@ -3308,6 +3318,7 @@ function buildDmMenuCard() {
3308
3318
  button("\u2699\uFE0F \u8BBE\u7F6E", { a: DM.settings })
3309
3319
  ]),
3310
3320
  actions([
3321
+ button("\u{1F4CA} \u7528\u91CF", { a: DM.usage }),
3311
3322
  button("\u{1FA7A} \u8BCA\u65AD", { a: DM.doctor }),
3312
3323
  button("\u{1F504} \u91CD\u8FDE", { a: DM.reconnect }),
3313
3324
  button("\u2B06\uFE0F \u7248\u672C\u66F4\u65B0", { a: DM.update })
@@ -4477,10 +4488,615 @@ async function restartDaemon() {
4477
4488
  await getServiceAdapter().restart();
4478
4489
  }
4479
4490
 
4491
+ // src/agent/codex-appserver/usage.ts
4492
+ import { readFile as readFile8 } from "fs/promises";
4493
+ import { homedir as homedir5 } from "os";
4494
+ import { join as join12 } from "path";
4495
+ var DEFAULT_BASE_URL = "https://chatgpt.com/backend-api";
4496
+ var HTTP_TIMEOUT_MS = 15e3;
4497
+ var REFRESH_TIMEOUT_MS = 2e4;
4498
+ var EXP_SKEW_MS = 6e4;
4499
+ var PROFILE_CACHE_MS = 5 * 6e4;
4500
+ var USAGE_CACHE_MS = 3e4;
4501
+ var UsageError = class extends Error {
4502
+ constructor(kind, message) {
4503
+ super(message);
4504
+ this.kind = kind;
4505
+ this.name = "UsageError";
4506
+ }
4507
+ kind;
4508
+ };
4509
+ function resolveCodexHome() {
4510
+ return process.env.CODEX_HOME ?? join12(homedir5(), ".codex");
4511
+ }
4512
+ async function readCodexAuth() {
4513
+ const file = join12(resolveCodexHome(), "auth.json");
4514
+ let lastErr;
4515
+ for (let i = 0; i < 3; i++) {
4516
+ let raw;
4517
+ try {
4518
+ raw = await readFile8(file, "utf8");
4519
+ } catch (err) {
4520
+ throw new UsageError("no-auth", `\u8BFB\u4E0D\u5230 ${file}\uFF1A${err instanceof Error ? err.message : String(err)}`);
4521
+ }
4522
+ try {
4523
+ const j = JSON.parse(raw);
4524
+ const accessToken = j.tokens?.access_token;
4525
+ if (!accessToken) throw new UsageError("api-key-mode", "auth.json \u6CA1\u6709 ChatGPT access_token\uFF08API-key \u767B\u5F55\u6A21\u5F0F\uFF09");
4526
+ return { accessToken, accountId: j.tokens?.account_id, lastRefresh: j.last_refresh };
4527
+ } catch (err) {
4528
+ if (err instanceof UsageError) throw err;
4529
+ lastErr = err;
4530
+ await new Promise((r) => setTimeout(r, 100));
4531
+ }
4532
+ }
4533
+ throw new UsageError("no-auth", `auth.json \u53CD\u590D\u89E3\u6790\u5931\u8D25\uFF1A${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
4534
+ }
4535
+ function jwtExpMs(token) {
4536
+ const part = token.split(".")[1];
4537
+ if (!part) return void 0;
4538
+ try {
4539
+ const payload = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
4540
+ return typeof payload.exp === "number" ? payload.exp * 1e3 : void 0;
4541
+ } catch {
4542
+ return void 0;
4543
+ }
4544
+ }
4545
+ async function chatgptBaseUrl() {
4546
+ try {
4547
+ const raw = await readFile8(join12(resolveCodexHome(), "config.toml"), "utf8");
4548
+ for (const line of raw.split("\n")) {
4549
+ const t = line.trim();
4550
+ if (t.startsWith("[")) break;
4551
+ const m = /^chatgpt_base_url\s*=\s*"([^"]+)"/.exec(t);
4552
+ if (m?.[1]) return m[1].replace(/\/+$/, "");
4553
+ }
4554
+ } catch {
4555
+ }
4556
+ return DEFAULT_BASE_URL;
4557
+ }
4558
+ var refreshInFlight = null;
4559
+ async function refreshViaAppServer() {
4560
+ if (refreshInFlight) return refreshInFlight;
4561
+ refreshInFlight = (async () => {
4562
+ const before = await readCodexAuth().catch(() => void 0);
4563
+ const bin = resolveCodexBin();
4564
+ if (!bin) return null;
4565
+ const client = new AppServerClient({ bin, cwd: process.cwd(), clientName: "feishu-codex-bridge-usage" });
4566
+ let account = void 0;
4567
+ try {
4568
+ await withDeadline2(client.connect(), REFRESH_TIMEOUT_MS, "usage-refresh connect");
4569
+ const res = await withDeadline2(
4570
+ client.request("account/read", { refreshToken: true }),
4571
+ REFRESH_TIMEOUT_MS,
4572
+ "account/read refresh"
4573
+ );
4574
+ account = res?.account;
4575
+ } catch (err) {
4576
+ log.fail("usage", err, { phase: "refresh" });
4577
+ return null;
4578
+ } finally {
4579
+ await client.close().catch(() => void 0);
4580
+ }
4581
+ const after = await readCodexAuth().catch(() => void 0);
4582
+ if (after && after.accessToken !== before?.accessToken) return after;
4583
+ if (account === null) return "permanent-failure";
4584
+ return null;
4585
+ })();
4586
+ try {
4587
+ return await refreshInFlight;
4588
+ } finally {
4589
+ refreshInFlight = null;
4590
+ }
4591
+ }
4592
+ function withDeadline2(p, ms, label) {
4593
+ return new Promise((resolve7, reject) => {
4594
+ const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
4595
+ p.then(
4596
+ (v) => {
4597
+ clearTimeout(t);
4598
+ resolve7(v);
4599
+ },
4600
+ (e) => {
4601
+ clearTimeout(t);
4602
+ reject(e);
4603
+ }
4604
+ );
4605
+ });
4606
+ }
4607
+ async function fetchWham(base, path, auth) {
4608
+ const ctl = new AbortController();
4609
+ const t = setTimeout(() => ctl.abort(), HTTP_TIMEOUT_MS);
4610
+ try {
4611
+ const resp = await fetch(`${base}${path}`, {
4612
+ headers: {
4613
+ Authorization: `Bearer ${auth.accessToken}`,
4614
+ ...auth.accountId ? { "ChatGPT-Account-Id": auth.accountId } : {},
4615
+ "User-Agent": "codex-cli"
4616
+ },
4617
+ signal: ctl.signal
4618
+ });
4619
+ if (!resp.ok) return { status: resp.status };
4620
+ return { status: resp.status, json: await resp.json() };
4621
+ } finally {
4622
+ clearTimeout(t);
4623
+ }
4624
+ }
4625
+ async function whamGet(path) {
4626
+ let auth = await readCodexAuth();
4627
+ const exp = jwtExpMs(auth.accessToken);
4628
+ if (exp !== void 0 && exp <= Date.now() + EXP_SKEW_MS) {
4629
+ const refreshed = await refreshViaAppServer();
4630
+ if (refreshed === "permanent-failure") throw new UsageError("need-relogin", "Codex \u767B\u5F55\u6001\u5DF2\u5931\u6548");
4631
+ if (refreshed) auth = refreshed;
4632
+ else throw new UsageError("transient", "\u767B\u5F55\u6001\u4E34\u671F\u4E14\u6682\u65F6\u65E0\u6CD5\u5237\u65B0");
4633
+ }
4634
+ const base = await chatgptBaseUrl();
4635
+ const attempt = async (a) => {
4636
+ try {
4637
+ return await fetchWham(base, path, a);
4638
+ } catch (err) {
4639
+ throw new UsageError("transient", `\u8BF7\u6C42\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`);
4640
+ }
4641
+ };
4642
+ let res = await attempt(auth);
4643
+ if (res.status === 401) {
4644
+ const fresh = await readCodexAuth();
4645
+ if (fresh.accessToken !== auth.accessToken) {
4646
+ auth = fresh;
4647
+ res = await attempt(auth).catch(() => res);
4648
+ }
4649
+ }
4650
+ if (res.status === 401) {
4651
+ const refreshed = await refreshViaAppServer();
4652
+ if (refreshed === "permanent-failure" || refreshed === null) {
4653
+ throw refreshed === null ? new UsageError("transient", "\u6682\u65F6\u65E0\u6CD5\u5237\u65B0 Codex \u767B\u5F55\u6001") : new UsageError("need-relogin", "Codex \u767B\u5F55\u6001\u5DF2\u5931\u6548");
4654
+ }
4655
+ res = await attempt(refreshed);
4656
+ if (res.status === 401) throw new UsageError("need-relogin", "\u5237\u65B0\u540E\u4ECD 401\uFF0C\u8D26\u53F7\u4FA7\u5DF2\u62D2\u7EDD");
4657
+ }
4658
+ if (res.json === void 0) throw new UsageError("transient", `HTTP ${res.status} (${path})`);
4659
+ return res.json;
4660
+ }
4661
+ function mapWindow(w) {
4662
+ if (!w || typeof w.used_percent !== "number") return void 0;
4663
+ return {
4664
+ usedPercent: Math.min(100, Math.max(0, w.used_percent)),
4665
+ windowSeconds: w.limit_window_seconds,
4666
+ resetAt: w.reset_at
4667
+ };
4668
+ }
4669
+ function mapUsageResponse(raw, fetchedAt) {
4670
+ const mapBucket = (rl, name) => ({
4671
+ ...name ? { name } : {},
4672
+ primary: mapWindow(rl?.primary_window),
4673
+ secondary: mapWindow(rl?.secondary_window)
4674
+ });
4675
+ return {
4676
+ planType: raw.plan_type,
4677
+ main: mapBucket(raw.rate_limit),
4678
+ extras: (raw.additional_rate_limits ?? []).filter((x) => x?.rate_limit).map((x) => mapBucket(x.rate_limit, x.limit_name)),
4679
+ fetchedAt
4680
+ };
4681
+ }
4682
+ function mapProfileResponse(raw) {
4683
+ const s = raw.stats ?? {};
4684
+ return {
4685
+ // 只用 display_name,绝不兜底 username——后者是邮箱 local part,会随可转发的
4686
+ // 分享卡泄出去;display_name 缺失时卡片侧降级「我的」。
4687
+ displayName: raw.profile?.display_name || void 0,
4688
+ lifetimeTokens: s.lifetime_tokens,
4689
+ peakDailyTokens: s.peak_daily_tokens,
4690
+ currentStreakDays: s.current_streak_days,
4691
+ longestStreakDays: s.longest_streak_days,
4692
+ longestTurnSec: s.longest_running_turn_sec,
4693
+ totalThreads: s.total_threads,
4694
+ fastModePct: s.fast_mode_usage_percentage,
4695
+ totalSkillsUsed: s.total_skills_used,
4696
+ uniqueSkillsUsed: s.unique_skills_used,
4697
+ mostUsedEffort: s.most_used_reasoning_effort,
4698
+ mostUsedEffortPct: s.most_used_reasoning_effort_percentage,
4699
+ topInvocations: (s.top_invocations ?? []).map((t) => ({
4700
+ name: t.plugin_name ?? t.skill_name ?? "",
4701
+ count: t.usage_count ?? 0,
4702
+ kind: t.plugin_name ? "plugin" : "skill"
4703
+ })).filter((t) => t.name),
4704
+ dailyBuckets: (s.daily_usage_buckets ?? []).filter((b) => typeof b.start_date === "string").map((b) => ({ date: b.start_date, tokens: b.tokens ?? 0 })),
4705
+ statsAsOf: raw.metadata?.stats_as_of
4706
+ };
4707
+ }
4708
+ var profileCache = null;
4709
+ var usageCache = null;
4710
+ async function fetchProfileStats(force = false) {
4711
+ if (!force && profileCache && Date.now() - profileCache.at < PROFILE_CACHE_MS) return profileCache.data;
4712
+ const raw = await whamGet("/wham/profiles/me");
4713
+ const data = mapProfileResponse(raw);
4714
+ profileCache = { at: Date.now(), data };
4715
+ return data;
4716
+ }
4717
+ async function fetchUsageSnapshot(force = false) {
4718
+ if (!force && usageCache && Date.now() - usageCache.at < USAGE_CACHE_MS) return usageCache.data;
4719
+ const raw = await whamGet("/wham/usage");
4720
+ const data = mapUsageResponse(raw, Date.now());
4721
+ usageCache = { at: Date.now(), data };
4722
+ return data;
4723
+ }
4724
+ async function fetchUsageBundle(force = false) {
4725
+ const [profile, usage] = await Promise.all([fetchProfileStats(force), fetchUsageSnapshot(force)]);
4726
+ return { profile, usage };
4727
+ }
4728
+
4729
+ // src/card/usage-cards.ts
4730
+ function formatTokensZh(n) {
4731
+ if (n === void 0 || n === null || Number.isNaN(n)) return "\u2014";
4732
+ const fmt = (v) => {
4733
+ const s = v.toFixed(1);
4734
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
4735
+ };
4736
+ if (n >= 1e8) return `${fmt(n / 1e8)}\u4EBF`;
4737
+ if (n >= 1e4) {
4738
+ const s = fmt(n / 1e4);
4739
+ return s === "10000" ? "1\u4EBF" : `${s}\u4E07`;
4740
+ }
4741
+ return n.toLocaleString("en-US");
4742
+ }
4743
+ function windowLabel(seconds) {
4744
+ if (!seconds) return "\u9650\u989D";
4745
+ if (seconds === 18e3) return "5 \u5C0F\u65F6";
4746
+ if (seconds === 604800) return "7 \u5929";
4747
+ return seconds < 86400 ? `${Math.round(seconds / 3600)} \u5C0F\u65F6` : `${Math.round(seconds / 86400)} \u5929`;
4748
+ }
4749
+ function resetLabel(resetAtSec, nowMs2 = Date.now()) {
4750
+ const d = new Date(resetAtSec * 1e3);
4751
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
4752
+ const dayKey = (x) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`;
4753
+ const now = new Date(nowMs2);
4754
+ if (dayKey(d) === dayKey(now)) return `\u4ECA\u5929 ${hm}`;
4755
+ const tomorrow = new Date(nowMs2 + 864e5);
4756
+ if (dayKey(d) === dayKey(tomorrow)) return `\u660E\u5929 ${hm}`;
4757
+ return `${d.getMonth() + 1}\u6708${d.getDate()}\u65E5 ${hm}`;
4758
+ }
4759
+ function localDateStr(d = /* @__PURE__ */ new Date()) {
4760
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
4761
+ }
4762
+ function toEpochDay(date) {
4763
+ const [y = 1970, m = 1, d = 1] = date.split("-").map(Number);
4764
+ return Date.UTC(y, m - 1, d) / 864e5;
4765
+ }
4766
+ function fromEpochDay(day) {
4767
+ const d = new Date(day * 864e5);
4768
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
4769
+ }
4770
+ function mondayOf(day) {
4771
+ const dow = new Date(day * 864e5).getUTCDay();
4772
+ return day - (dow + 6) % 7;
4773
+ }
4774
+ var DAY_LABELS = ["\u4E00", "\u4E8C", "\u4E09", "\u56DB", "\u4E94", "\u516D", "\u65E5"];
4775
+ function heatmapCells(buckets, today = localDateStr(), weeks = 14) {
4776
+ const todayDay = toEpochDay(today);
4777
+ const tokensByDay = /* @__PURE__ */ new Map();
4778
+ for (const b of buckets) tokensByDay.set(toEpochDay(b.date), b.tokens);
4779
+ const startMonday = mondayOf(todayDay) - (weeks - 1) * 7;
4780
+ const weekLabel = (c) => {
4781
+ const d = new Date((startMonday + c * 7) * 864e5);
4782
+ return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
4783
+ };
4784
+ const values = [];
4785
+ for (let c = 0; c < weeks; c++) {
4786
+ for (let r = 0; r < 7; r++) {
4787
+ const day = startMonday + c * 7 + r;
4788
+ if (day > todayDay) continue;
4789
+ const v = tokensByDay.get(day) ?? 0;
4790
+ const d = new Date(day * 864e5);
4791
+ const date = `${d.getUTCMonth() + 1}\u6708${d.getUTCDate()}\u65E5`;
4792
+ const label = v > 0 ? `${date} \u4F7F\u7528\u4E86 ${formatTokensZh(v)} Token` : `${date} \u65E0\u7528\u91CF`;
4793
+ values.push({ week: weekLabel(c), day: DAY_LABELS[r] ?? "", value: v, label });
4794
+ }
4795
+ }
4796
+ return { values, startDate: fromEpochDay(startMonday), endDate: today, weeks };
4797
+ }
4798
+ var HEAT_RANGE = ["#ebedf0", "#bbdefb", "#64b5f6", "#1e88e5", "#0d47a1"];
4799
+ var ROUNDED_CELL = "M -0.5 -0.25 Q -0.5 -0.5 -0.25 -0.5 L 0.25 -0.5 Q 0.5 -0.5 0.5 -0.25 L 0.5 0.25 Q 0.5 0.5 0.25 0.5 L -0.25 0.5 Q -0.5 0.5 -0.5 0.25 Z";
4800
+ function heatmapChartEl(buckets, today) {
4801
+ const h = heatmapCells(buckets, today);
4802
+ return {
4803
+ tag: "chart",
4804
+ aspect_ratio: "2:1",
4805
+ chart_spec: {
4806
+ type: "common",
4807
+ padding: 4,
4808
+ data: [{ id: "usage", values: h.values }],
4809
+ series: [
4810
+ {
4811
+ type: "heatmap",
4812
+ xField: "week",
4813
+ yField: "day",
4814
+ valueField: "label",
4815
+ cell: { style: { fill: { field: "value", scale: "color" }, shape: ROUNDED_CELL } }
4816
+ }
4817
+ ],
4818
+ color: { type: "linear", domain: [{ dataId: "usage", fields: ["value"] }], range: HEAT_RANGE },
4819
+ axes: [
4820
+ {
4821
+ orient: "bottom",
4822
+ type: "band",
4823
+ bandPadding: 0.25,
4824
+ domainLine: { visible: false },
4825
+ tick: { visible: false }
4826
+ },
4827
+ {
4828
+ orient: "left",
4829
+ type: "band",
4830
+ bandPadding: 0.25,
4831
+ domainLine: { visible: false },
4832
+ tick: { visible: false },
4833
+ label: { visible: false }
4834
+ }
4835
+ ],
4836
+ legends: { visible: false },
4837
+ tooltip: { visible: true, mark: { title: { visible: false } } }
4838
+ }
4839
+ };
4840
+ }
4841
+ function planLabel(plan) {
4842
+ if (!plan) return void 0;
4843
+ const m = {
4844
+ free: "Free",
4845
+ go: "Go",
4846
+ plus: "Plus",
4847
+ pro: "Pro",
4848
+ prolite: "Pro Lite",
4849
+ team: "Team",
4850
+ business: "Business",
4851
+ enterprise: "Enterprise",
4852
+ edu: "Edu",
4853
+ education: "Edu"
4854
+ };
4855
+ return m[plan] ?? plan.charAt(0).toUpperCase() + plan.slice(1);
4856
+ }
4857
+ function formatDurationZh(seconds) {
4858
+ if (seconds === void 0 || seconds === null || Number.isNaN(seconds) || seconds < 0) return "\u2014";
4859
+ const mins = Math.round(seconds / 60);
4860
+ if (mins < 60) return `${mins} \u5206`;
4861
+ const h = Math.floor(mins / 60);
4862
+ const rem = mins % 60;
4863
+ return rem ? `${h} \u5C0F\u65F6 ${rem} \u5206` : `${h} \u5C0F\u65F6`;
4864
+ }
4865
+ var remainingPct = (w) => Math.max(0, 100 - w.usedPercent);
4866
+ function progressChartEl(w) {
4867
+ const label = `${windowLabel(w.windowSeconds)}\u5269\u4F59`;
4868
+ return {
4869
+ tag: "chart",
4870
+ height: "40px",
4871
+ chart_spec: {
4872
+ type: "linearProgress",
4873
+ data: [{ id: "p", values: [{ type: label, value: remainingPct(w) / 100 }] }],
4874
+ xField: "value",
4875
+ yField: "type",
4876
+ cornerRadius: 8,
4877
+ bandWidth: 12,
4878
+ axes: [
4879
+ { orient: "left", type: "band", visible: false },
4880
+ { orient: "bottom", type: "linear", visible: false }
4881
+ ],
4882
+ tooltip: {
4883
+ visible: true,
4884
+ mark: { title: { visible: false }, content: [{ key: label, value: `${remainingPct(w)}%` }] }
4885
+ }
4886
+ }
4887
+ };
4888
+ }
4889
+ function rateLimitElements(bucket, nowMs2) {
4890
+ const out = [];
4891
+ const icons = ["\u26A1", "\u{1F4C5}"];
4892
+ [bucket.primary, bucket.secondary].forEach((w, i) => {
4893
+ if (!w) return;
4894
+ const reset = w.resetAt ? `\u3000<font color='grey'>${resetLabel(w.resetAt, nowMs2)} \u91CD\u7F6E</font>` : "";
4895
+ out.push(md(`${icons[i]} **${windowLabel(w.windowSeconds)}\u9650\u989D**\u3000\u5269\u4F59 ${remainingPct(w)}%${reset}`));
4896
+ out.push(progressChartEl(w));
4897
+ });
4898
+ if (!out.length) return [note("\u6682\u65E0\u9650\u989D\u6570\u636E")];
4899
+ return out;
4900
+ }
4901
+ function statColumns(items) {
4902
+ return {
4903
+ tag: "column_set",
4904
+ flex_mode: "flow",
4905
+ horizontal_spacing: "large",
4906
+ columns: items.map((it) => ({
4907
+ tag: "column",
4908
+ width: "auto",
4909
+ elements: [
4910
+ { tag: "markdown", content: `**${it.value}**`, text_size: "heading" },
4911
+ noteMd(it.label)
4912
+ ]
4913
+ }))
4914
+ };
4915
+ }
4916
+ function profileStatItems(p) {
4917
+ return [
4918
+ { value: formatTokensZh(p.lifetimeTokens), label: "\u7D2F\u8BA1 Token \u6570" },
4919
+ { value: formatTokensZh(p.peakDailyTokens), label: "\u5CF0\u503C Token \u6570" },
4920
+ { value: formatDurationZh(p.longestTurnSec), label: "\u6700\u957F\u4EFB\u52A1\u65F6\u957F" },
4921
+ { value: p.currentStreakDays !== void 0 ? `${p.currentStreakDays} \u5929` : "\u2014", label: "\u5F53\u524D\u8FDE\u7EED\u5929\u6570" },
4922
+ { value: p.longestStreakDays !== void 0 ? `${p.longestStreakDays} \u5929` : "\u2014", label: "\u6700\u957F\u8FDE\u7EED\u5929\u6570" }
4923
+ ];
4924
+ }
4925
+ function heatmapElements(p, today) {
4926
+ return [md("\u{1F4C8} **\u6BCF\u65E5 Token \u7528\u91CF**"), heatmapChartEl(p.dailyBuckets, today)];
4927
+ }
4928
+ function effortLabel(effort) {
4929
+ const m = { minimal: "\u6781\u4F4E", low: "\u4F4E", medium: "\u4E2D", high: "\u9AD8", xhigh: "\u8D85\u9AD8" };
4930
+ return m[effort] ?? effort;
4931
+ }
4932
+ function insightsElements(p) {
4933
+ const left = [];
4934
+ if (p.fastModePct !== void 0) left.push(`Fast Mode\u3000**${Math.round(p.fastModePct)}%**`);
4935
+ if (p.mostUsedEffort) {
4936
+ const pct = p.mostUsedEffortPct !== void 0 ? ` \xB7 ${Math.round(p.mostUsedEffortPct)}%` : "";
4937
+ left.push(`\u6700\u5E38\u7528\u63A8\u7406\u3000**${effortLabel(p.mostUsedEffort)}${pct}**`);
4938
+ }
4939
+ if (p.uniqueSkillsUsed !== void 0) left.push(`\u4F7F\u7528\u8FC7\u7684\u6280\u80FD\u3000**${p.uniqueSkillsUsed}**`);
4940
+ if (p.totalSkillsUsed !== void 0) left.push(`\u6280\u80FD\u8C03\u7528\u603B\u6570\u3000**${p.totalSkillsUsed.toLocaleString("en-US")}**`);
4941
+ if (p.totalThreads !== void 0) left.push(`\u4F1A\u8BDD\u603B\u6570\u3000**${p.totalThreads.toLocaleString("en-US")}**`);
4942
+ const right = p.topInvocations.slice(0, 5).map((t) => `${t.kind === "plugin" ? "@" : "$"}${t.name}\u3000**\xD7${t.count}**`);
4943
+ const col = (title, lines) => ({
4944
+ tag: "column",
4945
+ width: "weighted",
4946
+ weight: 1,
4947
+ elements: [md(`**${title}**`), noteMd(lines.join("\n"))]
4948
+ });
4949
+ const columns = [];
4950
+ if (left.length) columns.push(col("\u6D3B\u52A8\u6D1E\u5BDF", left));
4951
+ if (right.length) columns.push(col("\u5E38\u7528\u63D2\u4EF6 / \u6280\u80FD", right));
4952
+ if (!columns.length) return [];
4953
+ return [
4954
+ { tag: "column_set", flex_mode: columns.length === 2 ? "bisect" : "stretch", horizontal_spacing: "large", columns }
4955
+ ];
4956
+ }
4957
+ function joinWithHr(blocks) {
4958
+ const present = blocks.filter((b) => b.length);
4959
+ const out = [];
4960
+ present.forEach((b, i) => {
4961
+ if (i) out.push(hr());
4962
+ out.push(...b);
4963
+ });
4964
+ return out;
4965
+ }
4966
+ var usageButtons = () => actions([
4967
+ button("\u{1F504} \u5237\u65B0", { a: DM.usageRefresh }),
4968
+ button("\u{1F4E4} \u751F\u6210\u5206\u4EAB\u5361", { a: DM.usageShare }, "primary"),
4969
+ button("\u2B05\uFE0F \u83DC\u5355", { a: DM.menu })
4970
+ ]);
4971
+ var ERROR_COPY = {
4972
+ "no-auth": {
4973
+ title: "\u672A\u627E\u5230 Codex \u767B\u5F55\u6001",
4974
+ hint: "\u672C\u673A\u6CA1\u6709\u53EF\u8BFB\u7684 `~/.codex/auth.json`\uFF0C\u8BF7\u5728\u5BBF\u4E3B\u673A\u7EC8\u7AEF\u8FD0\u884C `codex login` \u540E\u91CD\u8BD5\u3002"
4975
+ },
4976
+ "api-key-mode": {
4977
+ title: "\u5F53\u524D\u662F API-key \u767B\u5F55\u6A21\u5F0F",
4978
+ hint: "\u7528\u91CF\u7EDF\u8BA1\u4E0E\u9650\u989D\u6570\u636E\u4EC5 **ChatGPT \u767B\u5F55**\uFF08`codex login`\uFF09\u53EF\u7528\uFF0CAPI-key \u6A21\u5F0F\u6CA1\u6709\u8FD9\u4EFD\u6570\u636E\u3002"
4979
+ },
4980
+ "need-relogin": {
4981
+ title: "Codex \u767B\u5F55\u6001\u5DF2\u5931\u6548",
4982
+ hint: "\u4EE4\u724C\u5DF2\u65E0\u6CD5\u5237\u65B0\uFF08\u8FC7\u671F/\u88AB\u64A4\u9500\uFF09\uFF0C\u8BF7\u5728\u5BBF\u4E3B\u673A\u7EC8\u7AEF\u91CD\u65B0\u8FD0\u884C `codex login`\u3002"
4983
+ },
4984
+ transient: {
4985
+ title: "\u6682\u65F6\u62C9\u4E0D\u5230\u6570\u636E",
4986
+ hint: "\u7F51\u7EDC\u6216 ChatGPT \u670D\u52A1\u6CE2\u52A8\uFF0C\u7A0D\u540E\u70B9\u300C\u{1F504} \u5237\u65B0\u300D\u91CD\u8BD5\u3002"
4987
+ }
4988
+ };
4989
+ function buildUsageCard(state) {
4990
+ if (state.phase === "loading") {
4991
+ return card([md("\u23F3 \u6B63\u5728\u62C9\u53D6 Codex \u7528\u91CF\u6570\u636E\u2026"), note("\u67E5\u8BE2 ChatGPT \u540E\u7AEF\uFF0C\u901A\u5E38 1~3 \u79D2\u3002")], {
4992
+ header: { title: "\u{1F4CA} Codex \u7528\u91CF", template: "wathet" },
4993
+ forward: false
4994
+ });
4995
+ }
4996
+ if (state.phase === "error") {
4997
+ const copy = ERROR_COPY[state.kind];
4998
+ return card(
4999
+ [
5000
+ md(`\u26A0\uFE0F **${copy.title}**`),
5001
+ md(copy.hint),
5002
+ ...state.kind === "transient" ? [note(state.message)] : [],
5003
+ usageButtons()
5004
+ ],
5005
+ { header: { title: "\u{1F4CA} Codex \u7528\u91CF", template: "orange" }, forward: false }
5006
+ );
5007
+ }
5008
+ const { profile, usage } = state.data;
5009
+ const nowMs2 = state.now ?? Date.now();
5010
+ const elements = joinWithHr([
5011
+ rateLimitElements(usage.main, nowMs2),
5012
+ [statColumns(profileStatItems(profile))],
5013
+ heatmapElements(profile, state.today),
5014
+ insightsElements(profile)
5015
+ ]);
5016
+ const plan = planLabel(usage.planType);
5017
+ elements.push(
5018
+ note(`\u7EDF\u8BA1\u622A\u81F3 ${profile.statsAsOf ?? "\u2014"}${plan ? ` \xB7 ${plan} \u5957\u9910` : ""} \xB7 \u6570\u636E\u6765\u81EA Codex \u4E2A\u4EBA\u8D44\u6599`),
5019
+ usageButtons()
5020
+ );
5021
+ return card(elements, {
5022
+ header: {
5023
+ title: "\u{1F4CA} Codex \u7528\u91CF",
5024
+ template: "wathet",
5025
+ ...profile.displayName ? { subtitle: profile.displayName } : {}
5026
+ },
5027
+ forward: false
5028
+ });
5029
+ }
5030
+ var SHARE_SECTIONS = [
5031
+ { key: "stats", label: "\u6838\u5FC3\u7EDF\u8BA1\uFF08\u7D2F\u8BA1 / \u5CF0\u503C / \u8FDE\u7EED\u5929\u6570\uFF09" },
5032
+ { key: "heatmap", label: "\u6BCF\u65E5\u7528\u91CF\u70ED\u529B\u56FE" },
5033
+ { key: "insights", label: "\u6D3B\u52A8\u6D1E\u5BDF\u4E0E\u5E38\u7528\u6280\u80FD" },
5034
+ { key: "limits", label: "\u9650\u989D\u8FDB\u5EA6\uFF085 \u5C0F\u65F6 / 7 \u5929\uFF09" },
5035
+ { key: "plan", label: "\u5957\u9910\u4FE1\u606F" }
5036
+ ];
5037
+ function parseShareSections(v) {
5038
+ const all = SHARE_SECTIONS.map((s) => s.key);
5039
+ const raw = Array.isArray(v) ? v : typeof v === "string" && v ? v.split(",") : [];
5040
+ const picked = raw.filter((x) => all.includes(String(x)));
5041
+ return new Set(picked.length ? picked : all);
5042
+ }
5043
+ function buildShareConfigCard(done = false) {
5044
+ return card(
5045
+ [
5046
+ md("\u9009\u62E9\u8981\u653E\u8FDB\u5206\u4EAB\u5361\u7684\u5185\u5BB9\uFF08**\u4E0D\u9009 = \u5168\u90E8\u5C55\u793A**\uFF09\uFF0C\u751F\u6210\u540E\u957F\u6309 / \u53F3\u952E\u5373\u53EF\u8F6C\u53D1\uFF1A"),
5047
+ {
5048
+ tag: "form",
5049
+ name: "shareCfg",
5050
+ elements: [
5051
+ {
5052
+ tag: "multi_select_static",
5053
+ name: "secs",
5054
+ placeholder: { tag: "plain_text", content: "\u9ED8\u8BA4\u5168\u90E8\u5C55\u793A\uFF0C\u53EF\u53EA\u6311\u90E8\u5206\u533A\u5757" },
5055
+ options: SHARE_SECTIONS.map((s) => ({ text: { tag: "plain_text", content: s.label }, value: s.key }))
5056
+ },
5057
+ submitButton("\u{1F4E4} \u751F\u6210\u5206\u4EAB\u5361", { a: DM.usageShareDo })
5058
+ ]
5059
+ },
5060
+ ...done ? [note("\u2705 \u5206\u4EAB\u5361\u5DF2\u751F\u6210\uFF08\u89C1\u4E0B\u65B9\u65B0\u5361\u7247\uFF09\u3002\u6362\u4E2A\u7EC4\u5408\u53EF\u518D\u6B21\u751F\u6210\u3002")] : [],
5061
+ actions([button("\u2B05\uFE0F \u8FD4\u56DE\u7528\u91CF", { a: DM.usage }), button("\u{1F3E0} \u83DC\u5355", { a: DM.menu })])
5062
+ ],
5063
+ { header: { title: "\u{1F4E4} \u5206\u4EAB\u5185\u5BB9\u9009\u62E9", template: "blue" }, forward: false }
5064
+ );
5065
+ }
5066
+ function buildUsageShareCard(data, opts = {}) {
5067
+ const { profile, usage } = data;
5068
+ const nowMs2 = opts.now ?? Date.now();
5069
+ const sec = opts.sections ?? new Set(SHARE_SECTIONS.map((s) => s.key));
5070
+ const who = profile.displayName ? `${profile.displayName} \u7684` : "\u6211\u7684";
5071
+ const plan = planLabel(usage.planType);
5072
+ const elements = joinWithHr([
5073
+ sec.has("stats") ? [statColumns(profileStatItems(profile))] : [],
5074
+ sec.has("heatmap") ? heatmapElements(profile, opts.today) : [],
5075
+ sec.has("insights") ? insightsElements(profile) : [],
5076
+ sec.has("limits") ? rateLimitElements(usage.main, nowMs2) : [],
5077
+ sec.has("plan") && plan ? [md(`\u{1F48E} **\u5957\u9910**\u3000${plan}`)] : []
5078
+ ]);
5079
+ const stamp = new Date(nowMs2);
5080
+ const stampStr = `${stamp.getMonth() + 1}\u6708${stamp.getDate()}\u65E5 ${String(stamp.getHours()).padStart(2, "0")}:${String(stamp.getMinutes()).padStart(2, "0")}`;
5081
+ elements.push({
5082
+ tag: "markdown",
5083
+ content: `<font color='grey'>\u{1F916} \u7531 </font>[feishu-codex-bridge](https://my.feishu.cn/docx/AFKNdf4QaooL5OxSR8bc5H7vn7b)<font color='grey'> \u4E8E ${stampStr} \u751F\u6210</font>`,
5084
+ text_size: "notation",
5085
+ text_align: "right"
5086
+ });
5087
+ return card(elements, {
5088
+ header: {
5089
+ title: `\u{1F4CA} ${who} Codex \u7528\u91CF`,
5090
+ template: "blue",
5091
+ ...profile.statsAsOf ? { subtitle: `\u7EDF\u8BA1\u622A\u81F3 ${profile.statsAsOf}` } : {}
5092
+ }
5093
+ });
5094
+ }
5095
+
4480
5096
  // src/project/lifecycle.ts
4481
5097
  import { mkdir as mkdir9 } from "fs/promises";
4482
5098
  import { existsSync as existsSync7 } from "fs";
4483
- import { isAbsolute as isAbsolute2, join as join12, resolve as resolve6 } from "path";
5099
+ import { isAbsolute as isAbsolute2, join as join13, resolve as resolve6 } from "path";
4484
5100
 
4485
5101
  // src/project/git-info.ts
4486
5102
  import { execFile } from "child_process";
@@ -4622,7 +5238,7 @@ async function resolveCwd(name, existingPath) {
4622
5238
  if (!existsSync7(cwd2)) throw new Error(`\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\uFF1A${cwd2}`);
4623
5239
  return { cwd: cwd2, blank: false };
4624
5240
  }
4625
- const cwd = join12(paths.projectsRootDir, name);
5241
+ const cwd = join13(paths.projectsRootDir, name);
4626
5242
  await mkdir9(cwd, { recursive: true });
4627
5243
  return { cwd, blank: true };
4628
5244
  }
@@ -4702,12 +5318,12 @@ async function leaveChat(channel, chatId) {
4702
5318
  }
4703
5319
 
4704
5320
  // src/bot/session-store.ts
4705
- import { mkdir as mkdir10, readFile as readFile8, rename as rename5, writeFile as writeFile8 } from "fs/promises";
5321
+ import { mkdir as mkdir10, readFile as readFile9, rename as rename5, writeFile as writeFile8 } from "fs/promises";
4706
5322
  import { dirname as dirname10 } from "path";
4707
5323
  var FILE_VERSION3 = 1;
4708
5324
  async function read2() {
4709
5325
  try {
4710
- const text = await readFile8(paths.sessionsFile, "utf8");
5326
+ const text = await readFile9(paths.sessionsFile, "utf8");
4711
5327
  const parsed = JSON.parse(text);
4712
5328
  return Array.isArray(parsed.sessions) ? parsed.sessions : [];
4713
5329
  } catch (err) {
@@ -4764,7 +5380,7 @@ async function handleDmConsole(channel, cfg, msg) {
4764
5380
 
4765
5381
  // src/bot/media.ts
4766
5382
  import { mkdir as mkdir11, readdir as readdir2, rm as rm4, stat as stat3 } from "fs/promises";
4767
- import { join as join13 } from "path";
5383
+ import { join as join14 } from "path";
4768
5384
  var MAX_IMAGES2 = 9;
4769
5385
  var MEDIA_TTL_MS = 60 * 6e4;
4770
5386
  var EXT_BY_CONTENT_TYPE = {
@@ -4791,7 +5407,7 @@ async function collectInboundImages(channel, msg) {
4791
5407
  return [];
4792
5408
  }
4793
5409
  if (refs.length === 0) return [];
4794
- await pruneOldMedia();
5410
+ await pruneOldMedia(paths.mediaDir);
4795
5411
  try {
4796
5412
  await mkdir11(paths.mediaDir, { recursive: true });
4797
5413
  } catch {
@@ -4869,7 +5485,7 @@ async function downloadOne(channel, ref, index) {
4869
5485
  params: { type: "image" }
4870
5486
  });
4871
5487
  const ext = extFromHeaders(res.headers);
4872
- const file = join13(paths.mediaDir, `${safeName(ref.fileKey)}-${index}.${ext}`);
5488
+ const file = join14(paths.mediaDir, `${safeName(ref.fileKey)}-${index}.${ext}`);
4873
5489
  await res.writeFile(file);
4874
5490
  return file;
4875
5491
  } catch (err) {
@@ -4894,16 +5510,16 @@ function readHeader(headers, name) {
4894
5510
  function safeName(fileKey) {
4895
5511
  return fileKey.replace(/[^a-zA-Z0-9_-]/g, "").slice(-40) || "img";
4896
5512
  }
4897
- async function pruneOldMedia() {
5513
+ async function pruneOldMedia(dir) {
4898
5514
  let entries;
4899
5515
  try {
4900
- entries = await readdir2(paths.mediaDir);
5516
+ entries = await readdir2(dir);
4901
5517
  } catch {
4902
5518
  return;
4903
5519
  }
4904
5520
  const cutoff = Date.now() - MEDIA_TTL_MS;
4905
5521
  for (const name of entries) {
4906
- const file = join13(paths.mediaDir, name);
5522
+ const file = join14(dir, name);
4907
5523
  try {
4908
5524
  const st = await stat3(file);
4909
5525
  if (st.mtimeMs < cutoff) await rm4(file, { force: true });
@@ -4911,6 +5527,84 @@ async function pruneOldMedia() {
4911
5527
  }
4912
5528
  }
4913
5529
  }
5530
+ var MAX_FILES = 9;
5531
+ var MAX_FILE_BYTES = 50 * 1024 * 1024;
5532
+ function messageHasFiles(msg) {
5533
+ return (msg.resources ?? []).some((r) => r.type === "file");
5534
+ }
5535
+ async function collectInboundFiles(channel, msg) {
5536
+ const refs = [];
5537
+ const seen = /* @__PURE__ */ new Set();
5538
+ for (const r of msg.resources ?? []) {
5539
+ if (r.type === "file" && r.fileKey && !seen.has(r.fileKey)) {
5540
+ seen.add(r.fileKey);
5541
+ refs.push({ messageId: msg.messageId, fileKey: r.fileKey, fileName: r.fileName });
5542
+ }
5543
+ }
5544
+ if (refs.length === 0) return [];
5545
+ await pruneOldMedia(paths.inboundDir);
5546
+ try {
5547
+ await mkdir11(paths.inboundDir, { recursive: true });
5548
+ } catch {
5549
+ }
5550
+ const out = [];
5551
+ for (const ref of refs.slice(0, MAX_FILES)) {
5552
+ const f = await downloadOneFile(channel, ref);
5553
+ if (f) out.push(f);
5554
+ }
5555
+ log.info("intake", "files", { found: refs.length, downloaded: out.length });
5556
+ return out;
5557
+ }
5558
+ async function downloadOneFile(channel, ref) {
5559
+ try {
5560
+ const res = await channel.rawClient.im.v1.messageResource.get({
5561
+ path: { message_id: ref.messageId, file_key: ref.fileKey },
5562
+ params: { type: "file" }
5563
+ });
5564
+ const declared = Number(readHeader(res.headers, "content-length"));
5565
+ if (Number.isFinite(declared) && declared > MAX_FILE_BYTES) {
5566
+ log.warn("intake", "file-too-large", { fileKey: ref.fileKey.slice(0, 24), bytes: declared });
5567
+ return void 0;
5568
+ }
5569
+ const name = cleanFileName(ref.fileName) || "attachment";
5570
+ const onDisk = `${safeName(ref.fileKey)}-${name}`;
5571
+ const file = join14(paths.inboundDir, onDisk);
5572
+ await res.writeFile(file);
5573
+ try {
5574
+ const st = await stat3(file);
5575
+ if (st.size > MAX_FILE_BYTES) {
5576
+ await rm4(file, { force: true });
5577
+ log.warn("intake", "file-too-large", { fileKey: ref.fileKey.slice(0, 24), bytes: st.size });
5578
+ return void 0;
5579
+ }
5580
+ } catch {
5581
+ }
5582
+ return { path: file, name };
5583
+ } catch (err) {
5584
+ log.warn("intake", "file-download-failed", { fileKey: ref.fileKey.slice(0, 24), err: String(err) });
5585
+ return void 0;
5586
+ }
5587
+ }
5588
+ function cleanFileName(name) {
5589
+ if (!name) return "";
5590
+ const base = name.split(/[/\\]/).pop() ?? name;
5591
+ const cleaned = base.replace(/[\x00-\x1f<>:"|?*]/g, "_").replace(/\s+/g, " ").trim().slice(0, 100);
5592
+ return cleaned === "." || cleaned === ".." ? "" : cleaned;
5593
+ }
5594
+ function stripFileTokens(text) {
5595
+ return text.replace(/<file\b[^<]*\/>/g, "").replace(/[ \t]+\n/g, "\n").trim();
5596
+ }
5597
+ function weaveFileManifest(text, files) {
5598
+ const stripped = stripFileTokens(text);
5599
+ if (files.length === 0) return stripped;
5600
+ const lines = files.map((f) => `- ${f.name} \u2192 ${f.path}`).join("\n");
5601
+ const head = stripped ? `${stripped}
5602
+
5603
+ ` : "";
5604
+ return `${head}[\u7528\u6237\u4E0A\u4F20\u4E86 ${files.length} \u4E2A\u9644\u4EF6\uFF0C\u5DF2\u4FDD\u5B58\u5230\u672C\u5730\uFF0C\u53EF\u7528 shell / \u8BFB\u53D6\u5DE5\u5177\u6309\u4E0B\u9762\u7684\u7EDD\u5BF9\u8DEF\u5F84\u76F4\u63A5\u6253\u5F00\uFF1A
5605
+ ${lines}
5606
+ ]`;
5607
+ }
4914
5608
 
4915
5609
  // src/bot/comments.ts
4916
5610
  var SUPPORTED_FILE_TYPES = /* @__PURE__ */ new Set(["doc", "docx", "sheet", "file"]);
@@ -5397,20 +6091,30 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5397
6091
  const perm = turnPerm(project, senderId);
5398
6092
  return { sessionKey: perm.roleSuffix ? `${baseKey}#${perm.roleSuffix}` : baseKey, ...perm };
5399
6093
  }
6094
+ async function ingestFiles(msg, text) {
6095
+ if (!messageHasFiles(msg)) return text;
6096
+ const files = await collectInboundFiles(channel, msg);
6097
+ const woven = weaveFileManifest(text, files);
6098
+ if (!woven.trim()) {
6099
+ return "\u7528\u6237\u53D1\u6765\u4E00\u4E2A\u9644\u4EF6\uFF0C\u4F46\u6865\u6CA1\u80FD\u4E0B\u8F7D\u5B83\uFF08\u53EF\u80FD\u8D85\u8FC7 50MB \u4E0A\u9650\u6216\u88AB\u98DE\u4E66\u62D2\u7EDD\uFF09\u3002\u8BF7\u544A\u8BC9\u7528\u6237\u9644\u4EF6\u6CA1\u8BFB\u5230\uFF0C\u53EF\u4EE5\u91CD\u53D1\uFF0C\u6216\u6539\u4E3A\u7C98\u8D34\u6587\u672C / \u53D1\u56FE\u7247\u3002";
6100
+ }
6101
+ return woven;
6102
+ }
5400
6103
  async function handleTurn(msg, text, sessionKey, flat, project, perm) {
5401
6104
  const existing = active.get(sessionKey);
5402
6105
  if (existing) {
5403
6106
  const images = messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0;
6107
+ const woven = await ingestFiles(msg, text);
5404
6108
  const cur = active.get(sessionKey);
5405
6109
  if (!cur) {
5406
- startReservedRun(msg, text, sessionKey, flat, project, perm, images);
6110
+ startReservedRun(msg, woven, sessionKey, flat, project, perm, images, true, text);
5407
6111
  return;
5408
6112
  }
5409
6113
  if (getPendingPolicy(cfg) === "steer" && cur.run && cur.thread) {
5410
6114
  const tid = cur.run.turnId();
5411
6115
  if (tid) {
5412
6116
  try {
5413
- await cur.thread.steer({ text, images }, tid);
6117
+ await cur.thread.steer({ text: woven, images }, tid);
5414
6118
  log.info("intake", "steer", { tid, images: images?.length ?? 0 });
5415
6119
  return;
5416
6120
  } catch (err) {
@@ -5418,13 +6122,13 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5418
6122
  }
5419
6123
  }
5420
6124
  }
5421
- cur.queue.push({ text, images });
6125
+ cur.queue.push({ text: woven, images });
5422
6126
  log.info("intake", "queued", { depth: cur.queue.length });
5423
6127
  return;
5424
6128
  }
5425
6129
  startReservedRun(msg, text, sessionKey, flat, project, perm);
5426
6130
  }
5427
- function startReservedRun(msg, text, sessionKey, flat, project, perm, preloadedImages) {
6131
+ function startReservedRun(msg, text, sessionKey, flat, project, perm, preloadedImages, preIngested, summaryText2) {
5428
6132
  const existing = active.get(sessionKey);
5429
6133
  if (existing) {
5430
6134
  existing.queue.push({ text, images: preloadedImages });
@@ -5437,6 +6141,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5437
6141
  const reaction = runReaction(msg.messageId, !sema.hasFree());
5438
6142
  try {
5439
6143
  const images = preloadedImages ?? (messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0);
6144
+ const firstText = preIngested ? text : await ingestFiles(msg, text);
5440
6145
  let thread = await resolveThread(sessionKey, msg.chatId, { mode: perm.mode, network: perm.network });
5441
6146
  if (!thread) {
5442
6147
  const cwd = project?.cwd ?? fallbackCwd;
@@ -5447,7 +6152,10 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5447
6152
  chatId: msg.chatId,
5448
6153
  cwd,
5449
6154
  codexThreadId: thread.codexThreadId,
5450
- summary: text.slice(0, 80),
6155
+ // `text` is already file-woven when preIngested; use the raw
6156
+ // `summaryText` (handleTurn's original) so the session label isn't
6157
+ // manifest boilerplate + a temp path.
6158
+ summary: stripFileTokens(summaryText2 ?? text).slice(0, 80),
5451
6159
  createdAt: Date.now(),
5452
6160
  updatedAt: Date.now()
5453
6161
  });
@@ -5460,7 +6168,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5460
6168
  replyInThread: !flat,
5461
6169
  flat,
5462
6170
  thread,
5463
- firstText: text,
6171
+ firstText,
5464
6172
  images,
5465
6173
  knownThreadId: sessionKey,
5466
6174
  requesterOpenId: msg.senderId
@@ -5534,8 +6242,8 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5534
6242
  await channel.send(msg.chatId, { markdown: `\u274C \u542F\u52A8\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}` }, { replyTo: msg.messageId }).catch(() => void 0);
5535
6243
  return;
5536
6244
  }
5537
- const firstText = text || "\u4F60\u597D\uFF0C\u6211\u4EEC\u5F00\u59CB\u5427\u3002";
5538
6245
  const images = messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0;
6246
+ const firstText = await ingestFiles(msg, text) || "\u4F60\u597D\uFF0C\u6211\u4EEC\u5F00\u59CB\u5427\u3002";
5539
6247
  log.info("card", "start", { project: project?.name ?? "(unregistered)", model, effort, images: images?.length ?? 0 });
5540
6248
  await launchRun(
5541
6249
  {
@@ -5548,7 +6256,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5548
6256
  model,
5549
6257
  effort,
5550
6258
  cwd,
5551
- summary: text.slice(0, 80) || "(\u7A7A)",
6259
+ summary: stripFileTokens(text).slice(0, 80) || "(\u7A7A)",
5552
6260
  requesterOpenId: msg.senderId,
5553
6261
  roleSuffix: perm.roleSuffix
5554
6262
  },
@@ -5714,6 +6422,46 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
5714
6422
  const freshMenu = (evt) => {
5715
6423
  patch(evt, buildDmMenuCard());
5716
6424
  };
6425
+ const runUsage = (evt, force) => {
6426
+ if (!dmAdmin(evt.operator?.openId)) return;
6427
+ void (async () => {
6428
+ await new Promise((r) => setTimeout(r, CARD_SETTLE_MS));
6429
+ let msgId = evt.messageId;
6430
+ const okLoading = await updateManagedCard(channel, msgId, buildUsageCard({ phase: "loading" })).catch(
6431
+ () => false
6432
+ );
6433
+ if (!okLoading) {
6434
+ const sent = await sendManagedCard(channel, evt.chatId, buildUsageCard({ phase: "loading" })).catch(
6435
+ (e) => {
6436
+ log.fail("console", e, { phase: "usage-loading" });
6437
+ return void 0;
6438
+ }
6439
+ );
6440
+ if (!sent) return;
6441
+ msgId = sent.messageId;
6442
+ }
6443
+ let state;
6444
+ try {
6445
+ state = { phase: "ready", data: await fetchUsageBundle(force) };
6446
+ } catch (err) {
6447
+ log.fail("console", err, { phase: "usage" });
6448
+ state = {
6449
+ phase: "error",
6450
+ kind: err instanceof UsageError ? err.kind : "transient",
6451
+ message: err instanceof Error ? err.message : String(err)
6452
+ };
6453
+ }
6454
+ const ok = await updateManagedCard(channel, msgId, buildUsageCard(state)).catch((e) => {
6455
+ log.fail("console", e, { phase: "usage-render" });
6456
+ return false;
6457
+ });
6458
+ if (!ok) {
6459
+ await sendManagedCard(channel, evt.chatId, buildUsageCard(state)).catch(
6460
+ (e) => log.fail("console", e, { phase: "usage-fallback" })
6461
+ );
6462
+ }
6463
+ })();
6464
+ };
5717
6465
  const renderProjectList = async () => {
5718
6466
  const [projects, sessions2] = await Promise.all([listProjects(), listSessions()]);
5719
6467
  const byChat = /* @__PURE__ */ new Map();
@@ -5867,6 +6615,25 @@ SDK \u4F1A\u81EA\u52A8\u91CD\u8FDE\uFF1B\u82E5\u957F\u671F\u65AD\u5F00\uFF0C\u8B
5867
6615
  await restartDaemon().catch((e) => log.fail("console", e, { phase: "update-restart" }));
5868
6616
  }
5869
6617
  })();
6618
+ }).on(DM.usage, ({ evt }) => runUsage(evt, false)).on(DM.usageRefresh, ({ evt }) => runUsage(evt, true)).on(DM.usageShare, ({ evt }) => {
6619
+ if (!dmAdmin(evt.operator?.openId)) return;
6620
+ patch(evt, buildShareConfigCard());
6621
+ }).on(DM.usageShareDo, ({ evt, formValue }) => {
6622
+ if (!dmAdmin(evt.operator?.openId)) return;
6623
+ const sections = parseShareSections(formValue?.secs);
6624
+ void (async () => {
6625
+ await new Promise((r) => setTimeout(r, CARD_SETTLE_MS));
6626
+ try {
6627
+ const data = await fetchUsageBundle();
6628
+ await sendManagedCard(channel, evt.chatId, buildUsageShareCard(data, { sections }), evt.messageId);
6629
+ log.info("console", "usage-share", { sections: [...sections].join(",") });
6630
+ await updateManagedCard(channel, evt.messageId, buildShareConfigCard(true)).catch(() => void 0);
6631
+ } catch (err) {
6632
+ log.fail("console", err, { phase: "usage-share" });
6633
+ const reason = err instanceof UsageError ? err.message : "\u62C9\u53D6\u7528\u91CF\u6570\u636E\u5931\u8D25";
6634
+ await channel.send(evt.chatId, { markdown: `\u26A0\uFE0F \u751F\u6210\u5206\u4EAB\u5361\u5931\u8D25\uFF1A${reason}` }, { replyTo: evt.messageId }).catch(() => void 0);
6635
+ }
6636
+ })();
5870
6637
  }).on(DM.rmConfirm, async ({ evt, value }) => {
5871
6638
  const name = typeof value.n === "string" ? value.n : void 0;
5872
6639
  if (!dmAdmin(evt.operator?.openId) || !name) return;
package/dist/index.d.ts CHANGED
@@ -42,6 +42,9 @@ declare const paths: {
42
42
  */
43
43
  secretsGetterScript: string;
44
44
  mediaDir: string;
45
+ /** Inbound file attachments downloaded from chat, handed to codex by absolute
46
+ * path (codex has no native file input). TTL-pruned like {@link mediaDir}. */
47
+ inboundDir: string;
45
48
  };
46
49
 
47
50
  export { log, newTraceId, paths, withTrace };
package/dist/index.js CHANGED
@@ -47,7 +47,10 @@ var paths = {
47
47
  * passes lark-cli's AssertSecurePath audit.
48
48
  */
49
49
  secretsGetterScript: join(appDir, "secrets-getter"),
50
- mediaDir: join(appDir, "media")
50
+ mediaDir: join(appDir, "media"),
51
+ /** Inbound file attachments downloaded from chat, handed to codex by absolute
52
+ * path (codex has no native file input). TTL-pruned like {@link mediaDir}. */
53
+ inboundDir: join(appDir, "inbound")
51
54
  };
52
55
 
53
56
  // src/core/logger.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelzen/feishu-codex-bridge",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Bridge Feishu/Lark messenger with local Codex via app-server (project=group, thread=session)",
5
5
  "type": "module",
6
6
  "bin": {