opencode-feishu-plugin 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -21,15 +21,19 @@ Connect [OpenCode](https://opencode.ai) to Feishu/Lark: **one Feishu topic = one
21
21
  | ✅ **Card approvals** | Permission requests become cards: allow once / always / this session only / deny — signed tokens prevent forgery and replay |
22
22
  | 📊 **Real-time visible** | "Thinking" receipt → live tool-call cards → streaming text updates; footer shows the current model |
23
23
  | ⏹ **Controllable** | Every reply card has a "force stop" button; a watchdog auto-interrupts stuck sessions; native queue with `/steer` `/now` to cut in |
24
+ | 📎 **Images / files** | Images and files sent in Feishu are downloaded and attached to the session, so vision/file-capable models can see and read them |
24
25
  | 🚫 **No ports** | Full long-connection; no inbound port needed on the server |
25
26
 
26
27
  ## 1. Feishu app setup (~3 minutes)
27
28
 
28
29
  1. Open the [Feishu Open Platform](https://open.feishu.cn/app) → **Create an enterprise self-built app**.
29
30
  2. **Add app capability → Bot**.
30
- 3. **Permission management** — enable only these two scopes:
31
+ 3. **Permission management** — enable these two minimal scopes:
31
32
  - `im:message.p2p_msg:readonly` — read messages users send to the bot in p2p chat
32
33
  - `im:message:send_as_bot` — send messages as the app (also used to update cards)
34
+
35
+ To **receive images / files** (downloaded and attached to sessions), add one more:
36
+ - `im:message:readonly` — fetch message resources (required to download images / files)
33
37
  4. **Events & Callbacks → Event configuration**: subscription method **"Use long connection to receive events"** (do **not** pick Webhook), add event `im.message.receive_v1`.
34
38
  5. **Events & Callbacks → Callback configuration**: same long-connection method, add callback `card.action.trigger` (zero permission requirement).
35
39
  6. **Version management & release**: availability = **only yourself**, create a version and **publish**. ⚠️ Without publishing the app stays in "development" state and the long connection cannot connect — the bot will never respond.
@@ -167,7 +171,7 @@ Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepa
167
171
 
168
172
  ## 6. Known limitations
169
173
 
170
- - Only **p2p text** (incl. rich text); images / files / audio-video get a text placeholder and aren't downloaded.
174
+ - Image / file messages are **downloaded and attached to the session** (requires `im:message:readonly`); audio / video / stickers still get a text placeholder.
171
175
  - Only takes over approvals for **Feishu-originated sessions**; local TUI sessions are unaffected.
172
176
  - One main path to create a session: the `/new` / `/form` form card.
173
177
  - Forms are JSON 2.0; old clients need ≥ V3.7.0 for `select_static`.
@@ -178,7 +182,7 @@ Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepa
178
182
 
179
183
  Iterating from real usage feedback; current plan:
180
184
 
181
- - [ ] **Accept images / files**: currently only p2p text is handled — images / files get a text placeholder. Planned: download images / files and **attach them to the session** so vision / file-capable models can see the images and read the file contents.
185
+ - [x] **Accept images / files**: done — downloaded automatically into the session working directory at `.opencode/temp/opencode-feishu-plugin/` (with a built-in `.gitignore`, so `git status` stays clean; override with `attachmentsDir`; default max 20MB per attachment).
182
186
  - [ ] **New messages cut in by default when busy**: currently new messages queue natively while a session is busy (manual cut-in via `/steer`, `/now`). Planned: new messages default to **cutting in immediately**, interrupting the current step to run first.
183
187
 
184
188
  ## Advanced topics & development
package/README.md CHANGED
@@ -21,15 +21,19 @@
21
21
  | ✅ **卡片审批** | 权限请求变飞书卡片:允许一次 / 始终允许 / 本会话内允许 / 拒绝,自签 token 防伪防重放 |
22
22
  | 📊 **实时可见** | 「思考中」回执 → 工具调用实时上卡 → 文本流式更新,页脚显示当前模型 |
23
23
  | ⏹ **可控可停** | 每张回复卡带「强制停止」;看门狗自动中断卡死会话;忙时原生排队,`/steer` `/now` 可插队 |
24
+ | 📎 **图片 / 文件** | 飞书里的图片 / 文件自动下载并挂进会话,支持视觉 / 文件的模型直接看图、读文件 |
24
25
  | 🚫 **无端口** | 全程长连接,服务器无需开放任何入站端口 |
25
26
 
26
27
  ## 一、飞书后台配置(约 3 分钟)
27
28
 
28
29
  1. 打开 [飞书开放平台](https://open.feishu.cn/app) → **创建企业自建应用**。
29
30
  2. **添加应用能力 → 机器人**。
30
- 3. **权限管理**,只开通这两个 scope:
31
+ 3. **权限管理**,开通这两个最小 scope:
31
32
  - `im:message.p2p_msg:readonly` —— 读取用户发给机器人的单聊消息
32
33
  - `im:message:send_as_bot` —— 以应用身份发消息(也用于更新卡片)
34
+
35
+ 如需**接收图片 / 文件**(下载后挂进会话),再加开一个:
36
+ - `im:message:readonly` —— 获取消息中的资源文件(图片 / 文件下载的必要条件)
33
37
  4. **事件与回调 → 事件配置**:订阅方式选**「使用长连接接收事件」**(不要选 Webhook),添加事件 `im.message.receive_v1`。
34
38
  5. **事件与回调 → 回调配置**:订阅方式同样选**长连接**,添加回调 `card.action.trigger`(零权限要求)。
35
39
  6. **版本管理与发布**:可用范围 = **仅本人**,创建版本并**发布**。⚠️ 不发布就是开发态,长连接连不上,机器人不会有任何反应。
@@ -167,7 +171,7 @@ agent 反问时表单会变成飞书卡片,**点按钮或在话题里直接发
167
171
 
168
172
  ## 六、已知限制
169
173
 
170
- - 只处理**单聊文本**(含富文本);图片 / 文件 / 音视频只给文字占位,不下载。
174
+ - 图片 / 文件消息会**下载到本地并挂进会话**(需开 `im:message:readonly`);音频 / 视频 / 表情包仍只给文字占位。
171
175
  - 只接管**从飞书发起的会话**的审批;本地 TUI 会话不受影响。
172
176
  - 建会话只有一条主路径:`/new` 与 `/form` 等价的表单卡。
173
177
  - 表单为 JSON 2.0,老客户端对 `select_static` 有最低版本要求(≥ V3.7.0)。
@@ -178,7 +182,7 @@ agent 反问时表单会变成飞书卡片,**点按钮或在话题里直接发
178
182
 
179
183
  按真实使用反馈迭代,当前规划:
180
184
 
181
- - [ ] **接收图片 / 文件**:目前只处理单聊文本,图片 / 文件仅显示文字占位。规划:下载图片 / 文件并作为**附件挂进会话**,让支持视觉 / 文件的模型直接看图、读文件内容。
185
+ - [x] **接收图片 / 文件**:已支持——自动下载到**会话工作目录**下的 `.opencode/temp/opencode-feishu-plugin/`(内置 `.gitignore`,不污染 `git status`;可用 `attachmentsDir` 覆盖;单附件默认 ≤20MB)。
182
186
  - [ ] **忙时新消息默认插队**:目前会话忙时新消息默认排队(可用 `/steer`、`/now` 手动插队)。规划:忙时你发的新消息**默认直接插队**,立即打断当前步骤优先执行。
183
187
 
184
188
  ## 高级主题与开发
package/dist/index.js CHANGED
@@ -1,17 +1,17 @@
1
1
  import crypto3, { randomBytes, createHash, createHmac, timingSafeEqual } from 'crypto';
2
- import url from 'url';
2
+ import url, { pathToFileURL } from 'url';
3
3
  import http from 'http';
4
4
  import https from 'https';
5
5
  import http2 from 'http2';
6
6
  import util from 'util';
7
- import path, { dirname, isAbsolute, sep, resolve as resolve$1, join, basename } from 'path';
7
+ import path, { dirname, isAbsolute, sep, join, resolve as resolve$1, basename } from 'path';
8
8
  import zlib from 'zlib';
9
9
  import stream3, { Readable } from 'stream';
10
10
  import { EventEmitter } from 'events';
11
11
  import fs, { mkdirSync, createWriteStream, readFileSync, realpathSync, statSync } from 'fs';
12
12
  import qs from 'querystring';
13
- import { homedir } from 'os';
14
- import { readFile, readdir } from 'fs/promises';
13
+ import { homedir, tmpdir } from 'os';
14
+ import { mkdir, stat, rm, writeFile, readFile, readdir } from 'fs/promises';
15
15
 
16
16
  var __create = Object.create;
17
17
  var __defProp = Object.defineProperty;
@@ -8845,7 +8845,7 @@ var require_mime_db = __commonJS({
8845
8845
  var require_mime_types = __commonJS({
8846
8846
  "node_modules/form-data/node_modules/mime-types/index.js"(exports) {
8847
8847
  var db = require_mime_db();
8848
- var extname = __require("path").extname;
8848
+ var extname2 = __require("path").extname;
8849
8849
  var EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/;
8850
8850
  var TEXT_TYPE_REGEXP = /^text\//i;
8851
8851
  exports.charset = charset;
@@ -8899,7 +8899,7 @@ var require_mime_types = __commonJS({
8899
8899
  if (!path2 || typeof path2 !== "string") {
8900
8900
  return false;
8901
8901
  }
8902
- var extension3 = extname("x." + path2).toLowerCase().substr(1);
8902
+ var extension3 = extname2("x." + path2).toLowerCase().substr(1);
8903
8903
  if (!extension3) {
8904
8904
  return false;
8905
8905
  }
@@ -10040,12 +10040,12 @@ var require_form_data = __commonJS({
10040
10040
  if (value3.end != void 0 && value3.end != Infinity && value3.start != void 0) {
10041
10041
  callback3(null, value3.end + 1 - (value3.start ? value3.start : 0));
10042
10042
  } else {
10043
- fs2.stat(value3.path, function(err2, stat) {
10043
+ fs2.stat(value3.path, function(err2, stat2) {
10044
10044
  if (err2) {
10045
10045
  callback3(err2);
10046
10046
  return;
10047
10047
  }
10048
- var fileSize = stat.size - (value3.start ? value3.start : 0);
10048
+ var fileSize = stat2.size - (value3.start ? value3.start : 0);
10049
10049
  callback3(null, fileSize);
10050
10050
  });
10051
10051
  }
@@ -144029,6 +144029,14 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144029
144029
  const runnerCardTextMax = clamp4(asNumber(merged.runnerCardTextMax, 2048), 512, 8192);
144030
144030
  const finalAnswerMinChars = clamp4(asNumber(merged.finalAnswerMinChars, 600), 0, 2e4);
144031
144031
  const finalAnswerFileMinBytes = clamp4(asNumber(merged.finalAnswerFileMinBytes, 20 * 1024), 8192, 102400);
144032
+ const acceptAttachments = asBoolean(merged.acceptAttachments, true);
144033
+ const attachmentMaxBytes = clamp4(
144034
+ asNumber(merged.attachmentMaxBytes, 20 * 1024 * 1024),
144035
+ 1024 * 1024,
144036
+ 100 * 1024 * 1024
144037
+ );
144038
+ const attachmentTimeoutMs = clamp4(asNumber(merged.attachmentTimeoutMs, 3e4), 5e3, 12e4);
144039
+ const attachmentsDirRaw = asString(merged.attachmentsDir).trim();
144032
144040
  const keepaliveIntervalMs = clamp4(
144033
144041
  asNumber(merged.keepaliveIntervalMs, 20 * 60 * 1e3),
144034
144042
  5 * 60 * 1e3,
@@ -144089,7 +144097,11 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144089
144097
  runnerCardMaxTools,
144090
144098
  runnerCardTextMax,
144091
144099
  finalAnswerMinChars,
144092
- finalAnswerFileMinBytes
144100
+ finalAnswerFileMinBytes,
144101
+ acceptAttachments,
144102
+ attachmentMaxBytes,
144103
+ attachmentTimeoutMs,
144104
+ ...attachmentsDirRaw ? { attachmentsDir: attachmentsDirRaw } : {}
144093
144105
  };
144094
144106
  }
144095
144107
  function loadConfigFile(env, deps, warnings) {
@@ -144706,6 +144718,27 @@ function extractPostText(rawContent) {
144706
144718
  return "";
144707
144719
  }
144708
144720
  }
144721
+ function parseIncomingAttachment(messageType, rawContent) {
144722
+ if (messageType !== "image" && messageType !== "file") return void 0;
144723
+ let parsed;
144724
+ try {
144725
+ parsed = JSON.parse(rawContent);
144726
+ } catch {
144727
+ return void 0;
144728
+ }
144729
+ if (!isRecordLike(parsed)) return void 0;
144730
+ if (messageType === "image") {
144731
+ const imageKey = str(parsed.image_key);
144732
+ return imageKey ? { kind: "image", fileKey: imageKey } : void 0;
144733
+ }
144734
+ const fileKey = str(parsed.file_key);
144735
+ if (!fileKey) return void 0;
144736
+ const fileName = str(parsed.file_name);
144737
+ return { kind: "file", fileKey, ...fileName ? { fileName } : {} };
144738
+ }
144739
+ function isRecordLike(value3) {
144740
+ return typeof value3 === "object" && value3 !== null && !Array.isArray(value3);
144741
+ }
144709
144742
  function parseIncomingMessage(data) {
144710
144743
  if (!isRecord2(data)) return void 0;
144711
144744
  const message = data.message;
@@ -144718,6 +144751,7 @@ function parseIncomingMessage(data) {
144718
144751
  if (!rawContent) return void 0;
144719
144752
  const sender = data.sender;
144720
144753
  const senderOpenId = isRecord2(sender) && isRecord2(sender.sender_id) ? str(sender.sender_id.open_id) : "";
144754
+ const attachment = parseIncomingAttachment(messageType, rawContent);
144721
144755
  return {
144722
144756
  eventId: str(data.event_id),
144723
144757
  messageId,
@@ -144725,6 +144759,7 @@ function parseIncomingMessage(data) {
144725
144759
  chatType: str(message.chat_type) || "p2p",
144726
144760
  messageType,
144727
144761
  text: extractMessageText(messageType, rawContent),
144762
+ ...attachment ? { attachment } : {},
144728
144763
  senderOpenId,
144729
144764
  ...str(message.create_time) ? { createTime: str(message.create_time) } : {},
144730
144765
  ...str(message.thread_id) ? { threadId: str(message.thread_id) } : {},
@@ -145221,6 +145256,127 @@ function createFeishuSender(client, log2, options2 = {}) {
145221
145256
  }
145222
145257
  };
145223
145258
  }
145259
+ var ATTACHMENT_DIR_RELATIVE = [".opencode", "temp", "opencode-feishu-plugin"];
145260
+ function resolveAttachmentDir(override, sessionDir) {
145261
+ if (override && override.trim()) return override.trim();
145262
+ if (sessionDir && sessionDir.trim()) return join(sessionDir.trim(), ...ATTACHMENT_DIR_RELATIVE);
145263
+ return join(tmpdir(), "opencode-feishu-plugin");
145264
+ }
145265
+ async function ensureAttachmentDirGitIgnored(dir) {
145266
+ try {
145267
+ await writeFile(join(dir, ".gitignore"), "*\n", { flag: "wx" });
145268
+ } catch {
145269
+ }
145270
+ }
145271
+ var AttachmentTimeoutError = class extends Error {
145272
+ constructor() {
145273
+ super("attachment-timeout");
145274
+ this.name = "AttachmentTimeoutError";
145275
+ }
145276
+ };
145277
+ function withTimeout(promise3, ms) {
145278
+ return new Promise((resolve4, reject) => {
145279
+ const timer = setTimeout(() => reject(new AttachmentTimeoutError()), Math.max(1, ms));
145280
+ timer.unref?.();
145281
+ promise3.then(
145282
+ (value3) => {
145283
+ clearTimeout(timer);
145284
+ resolve4(value3);
145285
+ },
145286
+ (err2) => {
145287
+ clearTimeout(timer);
145288
+ reject(err2);
145289
+ }
145290
+ );
145291
+ });
145292
+ }
145293
+ function sanitizeAttachmentName(raw, fallback = "attachment") {
145294
+ const base2 = (raw ?? "").trim();
145295
+ const cleaned = base2.replace(/[\\/]/g, "_").replace(/[\u0000-\u001f\u007f]/g, "").replace(/^\.+/, "").slice(0, 120);
145296
+ return cleaned || fallback;
145297
+ }
145298
+ var IMAGE_EXT_BY_MIME = {
145299
+ "image/png": ".png",
145300
+ "image/jpeg": ".jpg",
145301
+ "image/jpg": ".jpg",
145302
+ "image/gif": ".gif",
145303
+ "image/webp": ".webp",
145304
+ "image/bmp": ".bmp",
145305
+ "image/tiff": ".tiff"
145306
+ };
145307
+ function imageExtFromHeaders(headers) {
145308
+ const raw = headers?.["content-type"] ?? headers?.["Content-Type"];
145309
+ const mime = typeof raw === "string" ? raw.split(";")[0].trim().toLowerCase() : "";
145310
+ return IMAGE_EXT_BY_MIME[mime] ?? ".png";
145311
+ }
145312
+ function buildAttachmentFileName(attachment, headers) {
145313
+ if (attachment.kind === "image") {
145314
+ return sanitizeAttachmentName(`image${imageExtFromHeaders(headers)}`, "image.png");
145315
+ }
145316
+ const raw = sanitizeAttachmentName(attachment.fileName, "file");
145317
+ return raw;
145318
+ }
145319
+ function formatBytes(bytes) {
145320
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
145321
+ if (bytes < 1024) return `${bytes} B`;
145322
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
145323
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
145324
+ }
145325
+ function attachmentSavedPrompt(input) {
145326
+ const size6 = formatBytes(input.size);
145327
+ return input.kind === "image" ? `[\u9644\u4EF6] \u56FE\u7247\u300C${input.name}\u300D\u5DF2\u4FDD\u5B58\u5230 ${input.path}\uFF08${size6}\uFF09\uFF0C\u53EF\u76F4\u63A5\u67E5\u770B\u3002` : `[\u9644\u4EF6] \u6587\u4EF6\u300C${input.name}\u300D\u5DF2\u4FDD\u5B58\u5230 ${input.path}\uFF08${size6}\uFF09\u3002`;
145328
+ }
145329
+ async function downloadAttachment(input) {
145330
+ const { client, messageId, attachment, dir, maxBytes, timeoutMs, log: log2, gitIgnore } = input;
145331
+ const startedAt = Date.now();
145332
+ let target;
145333
+ try {
145334
+ await mkdir(dir, { recursive: true });
145335
+ if (gitIgnore) await ensureAttachmentDirGitIgnored(dir);
145336
+ const res = await withTimeout(
145337
+ client.im.messageResource.get({
145338
+ params: { type: attachment.kind },
145339
+ path: { message_id: messageId, file_key: attachment.fileKey }
145340
+ }),
145341
+ timeoutMs
145342
+ );
145343
+ if (!res || typeof res.writeFile !== "function") {
145344
+ return { ok: false, reason: "\u8D44\u6E90\u63A5\u53E3\u672A\u8FD4\u56DE\u6587\u4EF6\u6D41" };
145345
+ }
145346
+ const name = buildAttachmentFileName(attachment, res.headers);
145347
+ target = join(dir, `${messageId}-${name}`);
145348
+ await res.writeFile(target);
145349
+ const info = await stat(target);
145350
+ if (info.size > maxBytes) {
145351
+ await rm(target, { force: true });
145352
+ return {
145353
+ ok: false,
145354
+ reason: `\u9644\u4EF6\u8D85\u8FC7\u5927\u5C0F\u4E0A\u9650\uFF08${formatBytes(info.size)} > ${formatBytes(maxBytes)}\uFF09`
145355
+ };
145356
+ }
145357
+ log2.info("\u9644\u4EF6\u5DF2\u4E0B\u8F7D", {
145358
+ messageId,
145359
+ kind: attachment.kind,
145360
+ name,
145361
+ size: info.size,
145362
+ ms: Date.now() - startedAt
145363
+ });
145364
+ return { ok: true, path: target, name, size: info.size };
145365
+ } catch (err2) {
145366
+ if (target) await rm(target, { force: true }).catch(() => void 0);
145367
+ const reason = err2 instanceof AttachmentTimeoutError ? `\u4E0B\u8F7D\u8D85\u65F6\uFF08>${Math.round(timeoutMs / 1e3)}s\uFF09` : errorMessage(err2);
145368
+ log2.warn("\u9644\u4EF6\u4E0B\u8F7D\u5931\u8D25", { messageId, kind: attachment.kind, error: reason });
145369
+ return { ok: false, reason };
145370
+ }
145371
+ }
145372
+ function downloadedAttachmentPrompt(attachment, outcome) {
145373
+ return attachmentSavedPrompt({
145374
+ kind: attachment.kind,
145375
+ name: outcome.name,
145376
+ path: outcome.path,
145377
+ size: outcome.size
145378
+ });
145379
+ }
145224
145380
 
145225
145381
  // src/feishu/session-map.ts
145226
145382
  var CHAT_KEY_PREFIX = "feishu:v2:chat:";
@@ -148237,9 +148393,9 @@ function validateDirectory(rawPath, allowedRoots, deps = {}) {
148237
148393
  if (!isAbsolute(raw)) return err("not_absolute", "\u76EE\u5F55\u5FC5\u987B\u662F**\u7EDD\u5BF9\u8DEF\u5F84**\uFF08\u4EE5 `/` \u5F00\u5934\uFF09\u3002");
148238
148394
  target = resolve$1(raw);
148239
148395
  }
148240
- const stat = deps.statSync ?? statSync;
148396
+ const stat2 = deps.statSync ?? statSync;
148241
148397
  const realpath = deps.realpathSync ?? realpathSync;
148242
- const mkdir = deps.mkdirSync ?? mkdirSync;
148398
+ const mkdir2 = deps.mkdirSync ?? mkdirSync;
148243
148399
  const blocked = forbiddenReason(target);
148244
148400
  if (blocked) return blocked;
148245
148401
  if (!roots.some((root) => isUnder(target, root))) {
@@ -148247,13 +148403,13 @@ function validateDirectory(rawPath, allowedRoots, deps = {}) {
148247
148403
  }
148248
148404
  let exists2 = true;
148249
148405
  try {
148250
- if (!stat(target).isDirectory()) return err("not_dir", `\u4E0D\u662F\u76EE\u5F55\uFF1A\`${target}\``);
148406
+ if (!stat2(target).isDirectory()) return err("not_dir", `\u4E0D\u662F\u76EE\u5F55\uFF1A\`${target}\``);
148251
148407
  } catch {
148252
148408
  exists2 = false;
148253
148409
  }
148254
148410
  if (!exists2) {
148255
148411
  try {
148256
- mkdir(target, { recursive: true });
148412
+ mkdir2(target, { recursive: true });
148257
148413
  } catch {
148258
148414
  return err("create_failed", `\u76EE\u5F55\u4E0D\u5B58\u5728\u4E14\u65E0\u6CD5\u521B\u5EFA\uFF1A\`${target}\`\u3002\u8BF7\u68C0\u67E5\u8DEF\u5F84\u4E0E\u6743\u9650\u3002`);
148259
148415
  }
@@ -150731,7 +150887,7 @@ var SummaryTimeoutError = class extends Error {
150731
150887
  this.name = "SummaryTimeoutError";
150732
150888
  }
150733
150889
  };
150734
- function withTimeout(promise3, ms) {
150890
+ function withTimeout2(promise3, ms) {
150735
150891
  return new Promise((resolve4, reject) => {
150736
150892
  const timer = setTimeout(() => reject(new SummaryTimeoutError()), Math.max(1, ms));
150737
150893
  timer.unref?.();
@@ -150814,7 +150970,7 @@ async function summarizeSession(deps, input) {
150814
150970
  let messages;
150815
150971
  if (deps.readMessages) {
150816
150972
  try {
150817
- messages = await withTimeout(deps.readMessages(input.sessionID, input.directory), timeoutMs);
150973
+ messages = await withTimeout2(deps.readMessages(input.sessionID, input.directory), timeoutMs);
150818
150974
  const reused = extractLatestSummary(messages);
150819
150975
  if (reused) return { summary: reused, source: "reused" };
150820
150976
  } catch (err2) {
@@ -150827,7 +150983,7 @@ async function summarizeSession(deps, input) {
150827
150983
  if (!deps.generateText) return { source: "none" };
150828
150984
  try {
150829
150985
  const transcript = buildTranscript(messages);
150830
- const raw = await withTimeout(
150986
+ const raw = await withTimeout2(
150831
150987
  deps.generateText(buildSummaryPrompt(transcript), input.directory),
150832
150988
  timeoutMs
150833
150989
  );
@@ -151827,8 +151983,33 @@ async function start(ctx, config, log2, logSink) {
151827
151983
  ...model ? { model } : {}
151828
151984
  });
151829
151985
  if (!receipt.ok) log2.warn("\u56DE\u6267\u5361\u672A\u53D1\u9001\uFF0C\u4ECD\u7EE7\u7EED prompt", { sessionID, delivery });
151986
+ let promptText = message.text;
151987
+ const files = [];
151988
+ if (config.acceptAttachments && message.attachment) {
151989
+ const outcome = await downloadAttachment({
151990
+ client,
151991
+ messageId: message.messageId,
151992
+ attachment: message.attachment,
151993
+ dir: resolveAttachmentDir(config.attachmentsDir, link2?.dir),
151994
+ maxBytes: config.attachmentMaxBytes,
151995
+ timeoutMs: config.attachmentTimeoutMs,
151996
+ log: log2,
151997
+ // 显式配置的目录不动;默认的会话内目录顺手放 `.gitignore`,避免污染 git status。
151998
+ ...config.attachmentsDir ? {} : { gitIgnore: true }
151999
+ });
152000
+ if (outcome.ok) {
152001
+ files.push({ uri: pathToFileURL(outcome.path).href });
152002
+ promptText = `${promptText}
152003
+
152004
+ ${downloadedAttachmentPrompt(message.attachment, outcome)}`;
152005
+ } else {
152006
+ promptText = `${promptText}
152007
+
152008
+ [\u9644\u4EF6] \u4E0B\u8F7D\u5931\u8D25\uFF1A${outcome.reason}`;
152009
+ }
152010
+ }
151830
152011
  try {
151831
- await promptSession(ctx, sessionID, message.text, delivery);
152012
+ await promptSession(ctx, sessionID, promptText, delivery, files);
151832
152013
  } catch (err2) {
151833
152014
  log2.warn("prompt \u53D1\u9001\u5931\u8D25", { sessionID, error: errorMessage(err2) });
151834
152015
  runs.apply(sessionID, { type: "execution.failed", error: errorMessage(err2) });
@@ -152066,9 +152247,14 @@ async function replyPermission(ctx, input) {
152066
152247
  throw err2;
152067
152248
  }
152068
152249
  }
152069
- async function promptSession(ctx, sessionID, text, delivery) {
152250
+ async function promptSession(ctx, sessionID, text, delivery, files) {
152070
152251
  const api = ctx.session.prompt;
152071
- await api({ sessionID, text, delivery });
152252
+ await api({
152253
+ sessionID,
152254
+ text,
152255
+ delivery,
152256
+ ...files && files.length > 0 ? { files } : {}
152257
+ });
152072
152258
  }
152073
152259
  async function readSessionMessages(ctx, sessionID, directory, log2) {
152074
152260
  const options2 = directory ? { headers: { "x-opencode-directory": directory } } : void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Feishu/Lark plugin for OpenCode V2 — single-user topics as sessions, in-card permission approvals, streaming cards. Minimal permissions, no public endpoint.",
5
5
  "type": "module",
6
6
  "main": "index.js",