@modelzen/feishu-codex-bridge 0.3.4 → 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
@@ -165,6 +165,7 @@ feishu-codex-bridge bot rm <名> # 移除一个机器人配置
165
165
  - **💬 单会话群**:整群就是**一条连续会话**(全程**免 @**、消息按序排队、无 `/resume`)。适合**个人单线深入**、像私聊一样直接聊。
166
166
  - **干活**:在项目群里 **@机器人** 描述需求;机器人在该群绑定的目录里跑 Codex,流式卡片回结果。
167
167
  - **话题 = 会话**:对某条消息开话题后,话题内可**免 @** 连续对话,是一条连贯的 Codex 会话。
168
+ - **发图 / 发附件**:直接在消息里**发图片**,Codex 能看到(多模态读图);**发文件附件**(日志 / PDF / 代码等),桥会把它下载到本地并把**绝对路径**告诉 Codex,让它用工具直接打开分析。⚠️ 附件落在桥的全局临时目录(`~/.feishu-codex-bridge/inbound`,1h 后自动清),**只有「完全访问」档**能读到——「项目内只读 / 读写」档的沙箱把读取锁在项目目录内,读不到该目录。单文件上限 50MB、单条消息最多 9 个;合并转发里的附件飞书官方不支持取,故不支持。
168
169
  - **文档评论 @机器人**:在飞书文档评论里 @ 它就回(前提:已开通文档评论权限 + 订阅 `drive.notice.comment_add_v1`,且机器人对该文档有访问权限)。只支持 doc/docx/sheet/file;评论框不渲染 markdown,回复为纯文本,超长会截断。
169
170
  - **终止**:卡片上的 **⏹** 随时终止当前轮;卡死超过 watchdog 阈值(默认 120s)自动中止并回收进程。
170
171
  - **私聊控制台**:项目列表、设置(模型 / 推理强度 / 免 @ / watchdog / 管理员)、用量、诊断、重连,全在私聊菜单里。
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
@@ -5404,7 +5407,7 @@ async function collectInboundImages(channel, msg) {
5404
5407
  return [];
5405
5408
  }
5406
5409
  if (refs.length === 0) return [];
5407
- await pruneOldMedia();
5410
+ await pruneOldMedia(paths.mediaDir);
5408
5411
  try {
5409
5412
  await mkdir11(paths.mediaDir, { recursive: true });
5410
5413
  } catch {
@@ -5507,16 +5510,16 @@ function readHeader(headers, name) {
5507
5510
  function safeName(fileKey) {
5508
5511
  return fileKey.replace(/[^a-zA-Z0-9_-]/g, "").slice(-40) || "img";
5509
5512
  }
5510
- async function pruneOldMedia() {
5513
+ async function pruneOldMedia(dir) {
5511
5514
  let entries;
5512
5515
  try {
5513
- entries = await readdir2(paths.mediaDir);
5516
+ entries = await readdir2(dir);
5514
5517
  } catch {
5515
5518
  return;
5516
5519
  }
5517
5520
  const cutoff = Date.now() - MEDIA_TTL_MS;
5518
5521
  for (const name of entries) {
5519
- const file = join14(paths.mediaDir, name);
5522
+ const file = join14(dir, name);
5520
5523
  try {
5521
5524
  const st = await stat3(file);
5522
5525
  if (st.mtimeMs < cutoff) await rm4(file, { force: true });
@@ -5524,6 +5527,84 @@ async function pruneOldMedia() {
5524
5527
  }
5525
5528
  }
5526
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
+ }
5527
5608
 
5528
5609
  // src/bot/comments.ts
5529
5610
  var SUPPORTED_FILE_TYPES = /* @__PURE__ */ new Set(["doc", "docx", "sheet", "file"]);
@@ -6010,20 +6091,30 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
6010
6091
  const perm = turnPerm(project, senderId);
6011
6092
  return { sessionKey: perm.roleSuffix ? `${baseKey}#${perm.roleSuffix}` : baseKey, ...perm };
6012
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
+ }
6013
6103
  async function handleTurn(msg, text, sessionKey, flat, project, perm) {
6014
6104
  const existing = active.get(sessionKey);
6015
6105
  if (existing) {
6016
6106
  const images = messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0;
6107
+ const woven = await ingestFiles(msg, text);
6017
6108
  const cur = active.get(sessionKey);
6018
6109
  if (!cur) {
6019
- startReservedRun(msg, text, sessionKey, flat, project, perm, images);
6110
+ startReservedRun(msg, woven, sessionKey, flat, project, perm, images, true, text);
6020
6111
  return;
6021
6112
  }
6022
6113
  if (getPendingPolicy(cfg) === "steer" && cur.run && cur.thread) {
6023
6114
  const tid = cur.run.turnId();
6024
6115
  if (tid) {
6025
6116
  try {
6026
- await cur.thread.steer({ text, images }, tid);
6117
+ await cur.thread.steer({ text: woven, images }, tid);
6027
6118
  log.info("intake", "steer", { tid, images: images?.length ?? 0 });
6028
6119
  return;
6029
6120
  } catch (err) {
@@ -6031,13 +6122,13 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
6031
6122
  }
6032
6123
  }
6033
6124
  }
6034
- cur.queue.push({ text, images });
6125
+ cur.queue.push({ text: woven, images });
6035
6126
  log.info("intake", "queued", { depth: cur.queue.length });
6036
6127
  return;
6037
6128
  }
6038
6129
  startReservedRun(msg, text, sessionKey, flat, project, perm);
6039
6130
  }
6040
- function startReservedRun(msg, text, sessionKey, flat, project, perm, preloadedImages) {
6131
+ function startReservedRun(msg, text, sessionKey, flat, project, perm, preloadedImages, preIngested, summaryText2) {
6041
6132
  const existing = active.get(sessionKey);
6042
6133
  if (existing) {
6043
6134
  existing.queue.push({ text, images: preloadedImages });
@@ -6050,6 +6141,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
6050
6141
  const reaction = runReaction(msg.messageId, !sema.hasFree());
6051
6142
  try {
6052
6143
  const images = preloadedImages ?? (messageHasImages(msg) ? await collectInboundImages(channel, msg) : void 0);
6144
+ const firstText = preIngested ? text : await ingestFiles(msg, text);
6053
6145
  let thread = await resolveThread(sessionKey, msg.chatId, { mode: perm.mode, network: perm.network });
6054
6146
  if (!thread) {
6055
6147
  const cwd = project?.cwd ?? fallbackCwd;
@@ -6060,7 +6152,10 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
6060
6152
  chatId: msg.chatId,
6061
6153
  cwd,
6062
6154
  codexThreadId: thread.codexThreadId,
6063
- 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),
6064
6159
  createdAt: Date.now(),
6065
6160
  updatedAt: Date.now()
6066
6161
  });
@@ -6073,7 +6168,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
6073
6168
  replyInThread: !flat,
6074
6169
  flat,
6075
6170
  thread,
6076
- firstText: text,
6171
+ firstText,
6077
6172
  images,
6078
6173
  knownThreadId: sessionKey,
6079
6174
  requesterOpenId: msg.senderId
@@ -6147,8 +6242,8 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
6147
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);
6148
6243
  return;
6149
6244
  }
6150
- const firstText = text || "\u4F60\u597D\uFF0C\u6211\u4EEC\u5F00\u59CB\u5427\u3002";
6151
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";
6152
6247
  log.info("card", "start", { project: project?.name ?? "(unregistered)", model, effort, images: images?.length ?? 0 });
6153
6248
  await launchRun(
6154
6249
  {
@@ -6161,7 +6256,7 @@ function createOrchestrator(channel, cfg, fallbackCwd) {
6161
6256
  model,
6162
6257
  effort,
6163
6258
  cwd,
6164
- summary: text.slice(0, 80) || "(\u7A7A)",
6259
+ summary: stripFileTokens(text).slice(0, 80) || "(\u7A7A)",
6165
6260
  requesterOpenId: msg.senderId,
6166
6261
  roleSuffix: perm.roleSuffix
6167
6262
  },
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.4",
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": {