@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 +1 -0
- package/dist/cli.js +108 -13
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -1
- package/package.json +1 -1
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|