@xmanrui/dsh-im 4.9.1 → 4.10.0
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/PROACTIVE_DELIVERY.en.md +8 -0
- package/PROACTIVE_DELIVERY.md +8 -0
- package/lib/client.js +610 -56
- package/lib/index.js +273 -272
- package/package.json +12 -1
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +54 -0
- package/plugin-src/client/global-settings.js +341 -0
- package/plugin-src/client/i18n.js +29 -0
- package/plugin-src/client/index.js +47 -6
- package/plugin-src/client/styles.js +49 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
- package/plugin-src/host/channels/feishu/production.mjs +14 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/channels/qq/production.mjs +12 -0
- package/plugin-src/host/channels/shared/production.mjs +12 -0
- package/plugin-src/host/channels/slack/production.mjs +12 -0
- package/plugin-src/host/channels/wecom/production.mjs +12 -0
- package/plugin-src/host/channels/weixin/production.mjs +12 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
- package/plugin-src/host/delivery-adapter.mjs +1 -0
- package/plugin-src/host/delivery-service.mjs +15 -0
- package/plugin-src/host/harness-session-coordinator.mjs +26 -5
- package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
- package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
- package/plugin-src/host/inbound-ttl-service.mjs +179 -0
- package/plugin-src/host/index.mjs +8 -0
- package/plugin-src/host/modern-harness-api.mjs +58 -2
- package/src/channels/feishu/bridge.mjs +164 -4
- package/src/channels/feishu/feishu-channel.mjs +45 -10
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/message-utils.mjs +13 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/feishu/state-store.mjs +25 -0
- package/src/channels/shared/bot-workspace-store.mjs +6 -0
- package/src/channels/shared/inbound-file.mjs +117 -2
- package/src/channels/shared/inbound-ttl-store.mjs +110 -0
- package/src/channels/shared/inbound-ttl.mjs +22 -0
|
@@ -208,7 +208,7 @@ export class VerifiedFeishuChannel {
|
|
|
208
208
|
let activeCard = null;
|
|
209
209
|
let rotating = false;
|
|
210
210
|
try {
|
|
211
|
-
activeCard = await this.#createStreamCard(chatId, options
|
|
211
|
+
activeCard = await this.#createStreamCard(chatId, options);
|
|
212
212
|
cards.push(activeCard);
|
|
213
213
|
let lastContent = this.#initialText;
|
|
214
214
|
// issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
|
|
@@ -217,7 +217,7 @@ export class VerifiedFeishuChannel {
|
|
|
217
217
|
// 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
|
|
218
218
|
const ensureActiveCard = async () => {
|
|
219
219
|
if (!rotating) return activeCard;
|
|
220
|
-
activeCard = await this.#createStreamCard(chatId, options
|
|
220
|
+
activeCard = await this.#createStreamCard(chatId, options);
|
|
221
221
|
cards.push(activeCard);
|
|
222
222
|
rotating = false;
|
|
223
223
|
return activeCard;
|
|
@@ -255,7 +255,7 @@ export class VerifiedFeishuChannel {
|
|
|
255
255
|
for (const [index, chunk] of chunks.entries()) {
|
|
256
256
|
const card = index === 0
|
|
257
257
|
? await ensureActiveCard()
|
|
258
|
-
: await this.#createStreamCard(chatId, options
|
|
258
|
+
: await this.#createStreamCard(chatId, options);
|
|
259
259
|
if (index > 0) cards.push(card);
|
|
260
260
|
await this.#updateStreamCard(card, chunk);
|
|
261
261
|
await this.#finishStreamCard(card);
|
|
@@ -272,19 +272,33 @@ export class VerifiedFeishuChannel {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
async sendFile(chatId, file, {
|
|
275
|
+
async sendFile(chatId, file, {
|
|
276
|
+
replyTo,
|
|
277
|
+
signal,
|
|
278
|
+
replyInThread = false,
|
|
279
|
+
onReplyThreadId,
|
|
280
|
+
} = {}) {
|
|
276
281
|
return this.#sendArtifact(chatId, file, {
|
|
277
282
|
replyTo,
|
|
278
283
|
signal,
|
|
284
|
+
replyInThread,
|
|
285
|
+
onReplyThreadId,
|
|
279
286
|
messageType: 'file',
|
|
280
287
|
presentation: 'feishu-file',
|
|
281
288
|
});
|
|
282
289
|
}
|
|
283
290
|
|
|
284
|
-
async sendImage(chatId, file, {
|
|
291
|
+
async sendImage(chatId, file, {
|
|
292
|
+
replyTo,
|
|
293
|
+
signal,
|
|
294
|
+
replyInThread = false,
|
|
295
|
+
onReplyThreadId,
|
|
296
|
+
} = {}) {
|
|
285
297
|
return this.#sendArtifact(chatId, file, {
|
|
286
298
|
replyTo,
|
|
287
299
|
signal,
|
|
300
|
+
replyInThread,
|
|
301
|
+
onReplyThreadId,
|
|
288
302
|
messageType: 'image',
|
|
289
303
|
presentation: 'feishu-image',
|
|
290
304
|
});
|
|
@@ -293,6 +307,8 @@ export class VerifiedFeishuChannel {
|
|
|
293
307
|
async #sendArtifact(chatId, file, {
|
|
294
308
|
replyTo,
|
|
295
309
|
signal,
|
|
310
|
+
replyInThread = false,
|
|
311
|
+
onReplyThreadId,
|
|
296
312
|
messageType,
|
|
297
313
|
presentation,
|
|
298
314
|
}) {
|
|
@@ -349,7 +365,12 @@ export class VerifiedFeishuChannel {
|
|
|
349
365
|
const request = replyTo
|
|
350
366
|
? {
|
|
351
367
|
path: { message_id: replyTo },
|
|
352
|
-
data: {
|
|
368
|
+
data: {
|
|
369
|
+
msg_type: messageType,
|
|
370
|
+
content,
|
|
371
|
+
uuid,
|
|
372
|
+
...(replyInThread === true ? { reply_in_thread: true } : {}),
|
|
373
|
+
},
|
|
353
374
|
}
|
|
354
375
|
: {
|
|
355
376
|
params: { receive_id_type: 'chat_id' },
|
|
@@ -399,6 +420,10 @@ export class VerifiedFeishuChannel {
|
|
|
399
420
|
if (typeof messageId !== 'string' || !messageId) {
|
|
400
421
|
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
|
|
401
422
|
}
|
|
423
|
+
if (replyTo && typeof onReplyThreadId === 'function') {
|
|
424
|
+
const threadId = response?.data?.thread_id;
|
|
425
|
+
if (typeof threadId === 'string' && threadId) await onReplyThreadId(threadId);
|
|
426
|
+
}
|
|
402
427
|
return createDeliveryReceipt({
|
|
403
428
|
deliveryId: file.deliveryKey,
|
|
404
429
|
presentation,
|
|
@@ -410,7 +435,7 @@ export class VerifiedFeishuChannel {
|
|
|
410
435
|
});
|
|
411
436
|
}
|
|
412
437
|
|
|
413
|
-
async #createStreamCard(chatId,
|
|
438
|
+
async #createStreamCard(chatId, options = {}) {
|
|
414
439
|
const content = streamPreview(this.#initialText);
|
|
415
440
|
const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
|
|
416
441
|
data: {
|
|
@@ -420,7 +445,7 @@ export class VerifiedFeishuChannel {
|
|
|
420
445
|
}));
|
|
421
446
|
const cardId = response?.data?.card_id;
|
|
422
447
|
if (!cardId) throw new Error('Feishu card.create returned no card_id');
|
|
423
|
-
const messageId = await this.#sendCard(chatId, cardId,
|
|
448
|
+
const messageId = await this.#sendCard(chatId, cardId, options);
|
|
424
449
|
return { cardId, messageId, content, sequence: 0 };
|
|
425
450
|
}
|
|
426
451
|
|
|
@@ -455,12 +480,18 @@ export class VerifiedFeishuChannel {
|
|
|
455
480
|
assertApiSuccess('Feishu card.settings', response);
|
|
456
481
|
}
|
|
457
482
|
|
|
458
|
-
async #sendCard(chatId, cardId,
|
|
483
|
+
async #sendCard(chatId, cardId, options = {}) {
|
|
484
|
+
const replyTo = options.replyTo;
|
|
485
|
+
const replyInThread = options.replyInThread === true;
|
|
459
486
|
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
|
460
487
|
const response = replyTo
|
|
461
488
|
? await this.#client.im.v1.message.reply({
|
|
462
489
|
path: { message_id: replyTo },
|
|
463
|
-
data: {
|
|
490
|
+
data: {
|
|
491
|
+
msg_type: 'interactive',
|
|
492
|
+
content,
|
|
493
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
494
|
+
},
|
|
464
495
|
})
|
|
465
496
|
: await this.#client.im.v1.message.create({
|
|
466
497
|
params: { receive_id_type: 'chat_id' },
|
|
@@ -469,6 +500,10 @@ export class VerifiedFeishuChannel {
|
|
|
469
500
|
assertApiSuccess('Feishu message send', response);
|
|
470
501
|
const messageId = response?.data?.message_id;
|
|
471
502
|
if (!messageId) throw new Error('Feishu message send returned no message_id');
|
|
503
|
+
if (replyTo && typeof options.onReplyThreadId === 'function') {
|
|
504
|
+
const threadId = response?.data?.thread_id;
|
|
505
|
+
if (typeof threadId === 'string' && threadId) await options.onReplyThreadId(threadId);
|
|
506
|
+
}
|
|
472
507
|
return messageId;
|
|
473
508
|
}
|
|
474
509
|
|
|
@@ -110,6 +110,7 @@ export class FeishuRuntime {
|
|
|
110
110
|
#domain;
|
|
111
111
|
#botOpenId;
|
|
112
112
|
#groupResponseMode;
|
|
113
|
+
#groupTopicReply;
|
|
113
114
|
#ownerOpenIds;
|
|
114
115
|
#harness;
|
|
115
116
|
#state;
|
|
@@ -139,6 +140,7 @@ export class FeishuRuntime {
|
|
|
139
140
|
domain = 'feishu',
|
|
140
141
|
botOpenId,
|
|
141
142
|
groupResponseMode,
|
|
143
|
+
groupTopicReply = false,
|
|
142
144
|
ownerOpenId,
|
|
143
145
|
ownerOpenIds,
|
|
144
146
|
harness,
|
|
@@ -174,6 +176,7 @@ export class FeishuRuntime {
|
|
|
174
176
|
this.#domain = domain;
|
|
175
177
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
176
178
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
179
|
+
this.#groupTopicReply = groupTopicReply === true;
|
|
177
180
|
this.#ownerOpenIds = normalizedOwners;
|
|
178
181
|
this.#harness = harness;
|
|
179
182
|
this.#state = state;
|
|
@@ -198,6 +201,11 @@ export class FeishuRuntime {
|
|
|
198
201
|
this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
setGroupTopicReply(value) {
|
|
205
|
+
this.#groupTopicReply = value === true;
|
|
206
|
+
this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
|
|
207
|
+
}
|
|
208
|
+
|
|
201
209
|
async start() {
|
|
202
210
|
while (true) {
|
|
203
211
|
while (this.#stopping) await this.#stopping;
|
|
@@ -286,6 +294,7 @@ export class FeishuRuntime {
|
|
|
286
294
|
appId: this.#appId,
|
|
287
295
|
botOpenId: this.#botOpenId,
|
|
288
296
|
groupResponseMode: this.#groupResponseMode,
|
|
297
|
+
groupTopicReply: this.#groupTopicReply,
|
|
289
298
|
repair: this.#repair,
|
|
290
299
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
291
300
|
// Interaction cards (approval/question buttons) are on by default.
|
|
@@ -13,6 +13,19 @@ const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
|
|
|
13
13
|
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
14
14
|
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
|
15
15
|
|
|
16
|
+
/** Managed-topic group key: one per Feishu topic auto-rooted at a message. */
|
|
17
|
+
export function managedGroupKey(chatId, rootMessageId) {
|
|
18
|
+
if (!chatId) throw new Error('Feishu managed-topic key needs a chat_id');
|
|
19
|
+
if (!rootMessageId) throw new Error('Feishu managed-topic key needs a root message id');
|
|
20
|
+
return `group:${chatId}:managed:${rootMessageId}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether a key denotes a topic-isolated group conversation (thread or managed). */
|
|
24
|
+
export function isTopicGroupKey(key) {
|
|
25
|
+
return typeof key === 'string' && key.startsWith('group:')
|
|
26
|
+
&& (key.includes(':thread:') || key.includes(':managed:'));
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
export function conversationKey(event) {
|
|
17
30
|
const chatType = event?.message?.chat_type;
|
|
18
31
|
if (chatType === 'p2p') {
|
|
@@ -90,6 +90,7 @@ function configuredBotFingerprint(config) {
|
|
|
90
90
|
botOpenId: config.botOpenId,
|
|
91
91
|
activated: config.activated,
|
|
92
92
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
93
|
+
groupTopicReply: config.groupTopicReply === true,
|
|
93
94
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
94
95
|
deletionPending: config.deletionPending === true,
|
|
95
96
|
connectedAt: config.connectedAt ?? null,
|
|
@@ -584,6 +585,20 @@ export class MultiBotDshFeishuController {
|
|
|
584
585
|
}));
|
|
585
586
|
}
|
|
586
587
|
|
|
588
|
+
async updateGroupTopicReply(botId, groupTopicReply) {
|
|
589
|
+
this.#assertOpen();
|
|
590
|
+
if (typeof groupTopicReply !== 'boolean') {
|
|
591
|
+
throw new TypeError('Invalid Feishu group topic reply flag');
|
|
592
|
+
}
|
|
593
|
+
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
594
|
+
const config = this.#requireBot(botId);
|
|
595
|
+
const saved = await this.#configStore.saveBot({ ...config, groupTopicReply });
|
|
596
|
+
this.#runtimes.get(botId)?.setGroupTopicReply?.(saved.groupTopicReply);
|
|
597
|
+
this.#touch();
|
|
598
|
+
return this.status(botId);
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
|
|
587
602
|
async deleteBot(botId) {
|
|
588
603
|
this.#assertOpen();
|
|
589
604
|
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
@@ -646,6 +661,7 @@ export class MultiBotDshFeishuController {
|
|
|
646
661
|
connected,
|
|
647
662
|
configured: true,
|
|
648
663
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
664
|
+
groupTopicReply: config.groupTopicReply === true,
|
|
649
665
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
650
666
|
bot: publicBot(config),
|
|
651
667
|
connection,
|
|
@@ -45,6 +45,7 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|
|
45
45
|
botOpenId: cleanString(value.botOpenId),
|
|
46
46
|
activated: value.activated ?? null,
|
|
47
47
|
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
|
|
48
|
+
groupTopicReply: value.groupTopicReply === true,
|
|
48
49
|
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
49
50
|
deletionPending: value.deletionPending === true,
|
|
50
51
|
connectedAt: cleanString(value.connectedAt),
|
|
@@ -7,6 +7,7 @@ const EMPTY_STATE = Object.freeze({
|
|
|
7
7
|
seenMessageIds: [],
|
|
8
8
|
watches: {},
|
|
9
9
|
includeArchivedSessions: false,
|
|
10
|
+
topics: {},
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
/** One conversation key may watch at most this many sessions. */
|
|
@@ -40,6 +41,7 @@ export class StateStore {
|
|
|
40
41
|
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
|
|
41
42
|
? parsed.includeArchivedSessions
|
|
42
43
|
: false,
|
|
44
|
+
topics: parsed.topics && typeof parsed.topics === 'object' ? parsed.topics : {},
|
|
43
45
|
};
|
|
44
46
|
} catch (error) {
|
|
45
47
|
if (error?.code !== 'ENOENT') throw error;
|
|
@@ -136,6 +138,29 @@ export class StateStore {
|
|
|
136
138
|
return [...ids];
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
// ── Managed Feishu topics (thread_id → root message, persisted) ────────
|
|
142
|
+
|
|
143
|
+
topicRootFor(threadId) {
|
|
144
|
+
const entry = this.#state.topics[threadId];
|
|
145
|
+
return entry && typeof entry === 'object'
|
|
146
|
+
&& typeof entry.rootMessageId === 'string' && entry.rootMessageId.length > 0
|
|
147
|
+
&& typeof entry.chatId === 'string' && entry.chatId.length > 0
|
|
148
|
+
? { rootMessageId: entry.rootMessageId, chatId: entry.chatId }
|
|
149
|
+
: null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async setTopic(threadId, root) {
|
|
153
|
+
const validRoot = root && typeof root === 'object'
|
|
154
|
+
&& typeof root.rootMessageId === 'string' && root.rootMessageId.length > 0
|
|
155
|
+
&& typeof root.chatId === 'string' && root.chatId.length > 0;
|
|
156
|
+
if (!validRoot) throw new TypeError('Invalid Feishu topic root');
|
|
157
|
+
this.#state.topics[threadId] = {
|
|
158
|
+
rootMessageId: root.rootMessageId,
|
|
159
|
+
chatId: root.chatId,
|
|
160
|
+
};
|
|
161
|
+
await this.#persist();
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
// ── Session-list archived policy (per bot) ───────────────────
|
|
140
165
|
|
|
141
166
|
includesArchivedSessions() {
|
|
@@ -323,6 +323,12 @@ export class BotWorkspaceStore {
|
|
|
323
323
|
return Object.hasOwn(this.#workspaces, id) && !this.#removals.has(id);
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
listBotIds() {
|
|
327
|
+
return Object.keys(this.#workspaces)
|
|
328
|
+
.filter((botId) => !this.#removals.has(botId))
|
|
329
|
+
.sort();
|
|
330
|
+
}
|
|
331
|
+
|
|
326
332
|
incarnationFor(botId) {
|
|
327
333
|
return this.#incarnations.get(botIdOf(botId)) ?? null;
|
|
328
334
|
}
|
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { createWriteStream } from 'node:fs';
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { pipeline } from 'node:stream/promises';
|
|
5
5
|
|
|
6
6
|
import { t } from './i18n.mjs';
|
|
7
7
|
|
|
8
8
|
const FILES_DIRECTORY = join('.dsh-im', 'inbound');
|
|
9
|
+
const RETENTIONS = new Set(['turn', 'persistent']);
|
|
10
|
+
const DIRECTORY_TIMESTAMP_PATTERN = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/;
|
|
11
|
+
|
|
12
|
+
function padded(value) {
|
|
13
|
+
return String(value).padStart(2, '0');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Timestamp prefix (`yyyyMMdd-HHmmss-`) handed to mkdtemp for random suffixes. */
|
|
17
|
+
function inboundDirectoryPrefix(now = new Date()) {
|
|
18
|
+
return `${now.getFullYear()}${padded(now.getMonth() + 1)}${padded(now.getDate())}`
|
|
19
|
+
+ `-${padded(now.getHours())}${padded(now.getMinutes())}${padded(now.getSeconds())}-`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse the local-time instant encoded in a staged inbound directory name,
|
|
24
|
+
* returning null for legacy `turn-` names and anything unparseable.
|
|
25
|
+
*/
|
|
26
|
+
export function parseInboundDirectoryName(name) {
|
|
27
|
+
if (typeof name !== 'string') return null;
|
|
28
|
+
const match = DIRECTORY_TIMESTAMP_PATTERN.exec(name);
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
|
|
31
|
+
const date = new Date(year, month - 1, day, hour, minute, second);
|
|
32
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day
|
|
33
|
+
|| date.getHours() !== hour || date.getMinutes() !== minute || date.getSeconds() !== second) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return date;
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
export class InboundFileError extends Error {
|
|
11
40
|
constructor(code, message, userMessage = t('文件接收失败,请重新发送后再试。'), options = {}) {
|
|
@@ -97,7 +126,18 @@ export function prefetchInboundFiles(message, { signal } = {}) {
|
|
|
97
126
|
export async function stageInboundFiles(message, {
|
|
98
127
|
workspace,
|
|
99
128
|
signal,
|
|
129
|
+
retention = 'turn',
|
|
130
|
+
onStagedDirectory = null,
|
|
100
131
|
} = {}) {
|
|
132
|
+
if (!RETENTIONS.has(retention)) {
|
|
133
|
+
throw new InboundFileError(
|
|
134
|
+
'inbound-file-retention-invalid',
|
|
135
|
+
`Unknown inbound file retention: ${String(retention)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (onStagedDirectory !== null && typeof onStagedDirectory !== 'function') {
|
|
139
|
+
throw new TypeError('onStagedDirectory must be a function');
|
|
140
|
+
}
|
|
101
141
|
const sources = fileSources(message);
|
|
102
142
|
if (sources.length === 0) return null;
|
|
103
143
|
if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
|
|
@@ -110,7 +150,10 @@ export async function stageInboundFiles(message, {
|
|
|
110
150
|
signal?.throwIfAborted();
|
|
111
151
|
const root = resolve(workspace, FILES_DIRECTORY);
|
|
112
152
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
113
|
-
const directory = await mkdtemp(join(root,
|
|
153
|
+
const directory = await mkdtemp(join(root, inboundDirectoryPrefix()));
|
|
154
|
+
// Register the batch before any file is written so a concurrent TTL=0
|
|
155
|
+
// sweep can never observe the directory as untracked mid-staging.
|
|
156
|
+
onStagedDirectory?.(directory);
|
|
114
157
|
const files = [];
|
|
115
158
|
|
|
116
159
|
try {
|
|
@@ -177,7 +220,11 @@ export async function stageInboundFiles(message, {
|
|
|
177
220
|
}
|
|
178
221
|
return Object.freeze({
|
|
179
222
|
files: Object.freeze(files),
|
|
223
|
+
directory,
|
|
224
|
+
retention,
|
|
180
225
|
async cleanup() {
|
|
226
|
+
// Persistent retention leaves the directory to the global TTL sweeper.
|
|
227
|
+
if (retention !== 'turn') return;
|
|
181
228
|
await rm(directory, { recursive: true, force: true });
|
|
182
229
|
},
|
|
183
230
|
});
|
|
@@ -187,6 +234,74 @@ export async function stageInboundFiles(message, {
|
|
|
187
234
|
}
|
|
188
235
|
}
|
|
189
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Delete expired staged inbound directories under one workspace. Only
|
|
239
|
+
* directories whose names parse as `yyyyMMdd-HHmmss` timestamps are managed;
|
|
240
|
+
* legacy `turn-` names and anything unparseable stay untouched. Directories
|
|
241
|
+
* reported as tracked by an in-flight turn are always skipped.
|
|
242
|
+
*/
|
|
243
|
+
export async function sweepInboundAttachments(workspace, ttlHours, {
|
|
244
|
+
now = () => new Date(),
|
|
245
|
+
isTracked,
|
|
246
|
+
} = {}) {
|
|
247
|
+
if (typeof workspace !== 'string' || !workspace) {
|
|
248
|
+
throw new TypeError('sweepInboundAttachments requires a workspace');
|
|
249
|
+
}
|
|
250
|
+
// An unparsable TTL must never widen into unconditional deletion.
|
|
251
|
+
if (ttlHours !== 0 && !(Number.isInteger(ttlHours) && ttlHours > 0)) {
|
|
252
|
+
return { deleted: 0 };
|
|
253
|
+
}
|
|
254
|
+
const root = resolve(workspace, FILES_DIRECTORY);
|
|
255
|
+
let canonicalWorkspace;
|
|
256
|
+
let canonicalRoot;
|
|
257
|
+
try {
|
|
258
|
+
[canonicalWorkspace, canonicalRoot] = await Promise.all([
|
|
259
|
+
realpath(workspace),
|
|
260
|
+
realpath(root),
|
|
261
|
+
]);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code === 'ENOENT') return { deleted: 0 };
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
const rootInWorkspace = relative(canonicalWorkspace, canonicalRoot);
|
|
267
|
+
if (!rootInWorkspace || rootInWorkspace.startsWith('..') || isAbsolute(rootInWorkspace)) {
|
|
268
|
+
throw new InboundFileError(
|
|
269
|
+
'inbound-file-root-outside-workspace',
|
|
270
|
+
'The inbound attachment directory does not resolve inside the Harness Session workspace.',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
let entries;
|
|
274
|
+
try {
|
|
275
|
+
entries = await readdir(canonicalRoot, { withFileTypes: true });
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error?.code === 'ENOENT') return { deleted: 0 };
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
const current = now();
|
|
281
|
+
let deleted = 0;
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
if (!entry.isDirectory()) continue;
|
|
284
|
+
const stagedAt = parseInboundDirectoryName(entry.name);
|
|
285
|
+
if (stagedAt === null) continue;
|
|
286
|
+
const directory = join(root, entry.name);
|
|
287
|
+
const canonicalDirectory = join(canonicalRoot, entry.name);
|
|
288
|
+
if (typeof isTracked === 'function'
|
|
289
|
+
&& (isTracked(directory) || (directory !== canonicalDirectory && isTracked(canonicalDirectory)))) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const ageHours = (current - stagedAt) / 3_600_000;
|
|
293
|
+
if (ttlHours === 0 || ageHours >= ttlHours) {
|
|
294
|
+
try {
|
|
295
|
+
await rm(canonicalDirectory, { recursive: true, force: true });
|
|
296
|
+
deleted += 1;
|
|
297
|
+
} catch {
|
|
298
|
+
// One undeletable directory must not abort the remaining sweep.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { deleted };
|
|
303
|
+
}
|
|
304
|
+
|
|
190
305
|
export function appendInboundFilesToPrompt(prompt, staged) {
|
|
191
306
|
if (!staged?.files?.length) return prompt;
|
|
192
307
|
const manifest = [
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_INBOUND_TTL_HOURS, normalizeInboundTtlHours } from './inbound-ttl.mjs';
|
|
6
|
+
|
|
7
|
+
const DOCUMENT_VERSION = 1;
|
|
8
|
+
// Fail-safe TTL for documents that exist but cannot be trusted (damage or an
|
|
9
|
+
// unknown future version, e.g. after a rollback). TTL 0 would sweep every
|
|
10
|
+
// untracked directory on the next pass, so keep-forever is the safe fallback.
|
|
11
|
+
const UNREADABLE_DOCUMENT_TTL_HOURS = -1;
|
|
12
|
+
|
|
13
|
+
function invalidTtlError() {
|
|
14
|
+
const error = new Error('Invalid inbound attachment TTL hours.');
|
|
15
|
+
error.code = 'inbound-ttl-invalid';
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Mirrors the atomic settings writes of the bot workspace store and the
|
|
20
|
+
// update service: create a private temporary file, then rename it into place.
|
|
21
|
+
async function writeSettingsDocument(path, document) {
|
|
22
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
mode: 0o600,
|
|
28
|
+
flag: 'wx',
|
|
29
|
+
});
|
|
30
|
+
await rename(temporary, path);
|
|
31
|
+
} finally {
|
|
32
|
+
await unlink(temporary).catch((error) => {
|
|
33
|
+
if (error.code !== 'ENOENT') throw error;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Durable host-side settings for the global inbound attachment TTL. */
|
|
39
|
+
export class InboundTtlStore {
|
|
40
|
+
#path;
|
|
41
|
+
#ttlHours = DEFAULT_INBOUND_TTL_HOURS;
|
|
42
|
+
|
|
43
|
+
constructor(path) {
|
|
44
|
+
if (typeof path !== 'string' || !path) throw new TypeError('inbound TTL store path is required');
|
|
45
|
+
this.#path = path;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A missing file means first run (default TTL). A damaged or future
|
|
50
|
+
* document falls back to keep-forever so an unreadable intent can never
|
|
51
|
+
* widen into deletion; the value stays visible in the settings UI.
|
|
52
|
+
*/
|
|
53
|
+
async load() {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = await readFile(this.#path, 'utf8');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
59
|
+
this.#ttlHours = DEFAULT_INBOUND_TTL_HOURS;
|
|
60
|
+
await this.#removeStaleTemporaries();
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
this.#ttlHours = this.#readTtlHours(raw) ?? UNREADABLE_DOCUMENT_TTL_HOURS;
|
|
64
|
+
await this.#removeStaleTemporaries();
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Crash leftovers from an interrupted atomic write are unreferenced by
|
|
69
|
+
// anyone; remove them so the settings directory stays clean.
|
|
70
|
+
async #removeStaleTemporaries() {
|
|
71
|
+
const directory = dirname(this.#path);
|
|
72
|
+
const prefix = `${basename(this.#path)}.`;
|
|
73
|
+
try {
|
|
74
|
+
const entries = await readdir(directory);
|
|
75
|
+
await Promise.all(entries
|
|
76
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
|
|
77
|
+
.map((name) => unlink(join(directory, name)).catch(() => {})));
|
|
78
|
+
} catch {
|
|
79
|
+
// A missing directory or concurrent removal is fine; cleanup is best-effort.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#readTtlHours(raw) {
|
|
84
|
+
let document;
|
|
85
|
+
try {
|
|
86
|
+
document = JSON.parse(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) return null;
|
|
91
|
+
if (document.version !== DOCUMENT_VERSION) return null;
|
|
92
|
+
if (document.inboundAttachmentTtlHours === undefined) return DEFAULT_INBOUND_TTL_HOURS;
|
|
93
|
+
return normalizeInboundTtlHours(document.inboundAttachmentTtlHours);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getTtlHours() {
|
|
97
|
+
return this.#ttlHours;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async setTtlHours(value) {
|
|
101
|
+
const ttlHours = normalizeInboundTtlHours(value);
|
|
102
|
+
if (ttlHours === null) throw invalidTtlError();
|
|
103
|
+
await writeSettingsDocument(this.#path, {
|
|
104
|
+
version: DOCUMENT_VERSION,
|
|
105
|
+
inboundAttachmentTtlHours: ttlHours,
|
|
106
|
+
});
|
|
107
|
+
this.#ttlHours = ttlHours;
|
|
108
|
+
return ttlHours;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const DEFAULT_INBOUND_TTL_HOURS = 168;
|
|
2
|
+
export const INBOUND_TTL_MAX_HOURS = 8760;
|
|
3
|
+
|
|
4
|
+
const INTEGER_STRING = /^[+-]?\d+$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a global inbound attachment TTL. Valid values are -1 (keep
|
|
8
|
+
* forever), 0 (delete once the owning turn ends), and whole hours 1..8760.
|
|
9
|
+
* Integer strings are tolerated; every other input returns null.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeInboundTtlHours(value) {
|
|
12
|
+
let candidate = value;
|
|
13
|
+
if (typeof candidate === 'string') {
|
|
14
|
+
const trimmed = candidate.trim();
|
|
15
|
+
if (!INTEGER_STRING.test(trimmed)) return null;
|
|
16
|
+
candidate = Number(trimmed);
|
|
17
|
+
}
|
|
18
|
+
if (typeof candidate !== 'number' || !Number.isInteger(candidate)) return null;
|
|
19
|
+
if (candidate === -1) return -1;
|
|
20
|
+
if (candidate >= 0 && candidate <= INBOUND_TTL_MAX_HOURS) return candidate;
|
|
21
|
+
return null;
|
|
22
|
+
}
|