@xmanrui/dsh-im 4.9.0 → 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.
Files changed (47) hide show
  1. package/PROACTIVE_DELIVERY.en.md +8 -0
  2. package/PROACTIVE_DELIVERY.md +8 -0
  3. package/lib/client.js +679 -73
  4. package/lib/index.js +276 -275
  5. package/package.json +21 -6
  6. package/plugin-src/client/channels/dingtalk/api.js +23 -6
  7. package/plugin-src/client/channels/dingtalk/index.js +33 -6
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -0
  9. package/plugin-src/client/channels/feishu/api.js +2 -0
  10. package/plugin-src/client/channels/feishu/index.js +54 -0
  11. package/plugin-src/client/global-settings.js +341 -0
  12. package/plugin-src/client/i18n.js +33 -0
  13. package/plugin-src/client/index.js +47 -6
  14. package/plugin-src/client/styles.js +49 -0
  15. package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
  16. package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
  17. package/plugin-src/host/channels/feishu/production.mjs +14 -0
  18. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  19. package/plugin-src/host/channels/qq/production.mjs +12 -0
  20. package/plugin-src/host/channels/shared/production.mjs +12 -0
  21. package/plugin-src/host/channels/slack/production.mjs +12 -0
  22. package/plugin-src/host/channels/wecom/production.mjs +12 -0
  23. package/plugin-src/host/channels/weixin/production.mjs +12 -0
  24. package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
  25. package/plugin-src/host/delivery-adapter.mjs +1 -0
  26. package/plugin-src/host/delivery-service.mjs +15 -0
  27. package/plugin-src/host/harness-session-coordinator.mjs +26 -5
  28. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  29. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  30. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  31. package/plugin-src/host/index.mjs +8 -0
  32. package/plugin-src/host/modern-harness-api.mjs +75 -10
  33. package/src/channels/dingtalk/connection-error.mjs +299 -0
  34. package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
  35. package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
  36. package/src/channels/feishu/bridge.mjs +164 -4
  37. package/src/channels/feishu/feishu-channel.mjs +45 -10
  38. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  39. package/src/channels/feishu/message-utils.mjs +13 -0
  40. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  41. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  42. package/src/channels/feishu/state-store.mjs +25 -0
  43. package/src/channels/shared/bot-workspace-store.mjs +6 -0
  44. package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
  45. package/src/channels/shared/inbound-file.mjs +117 -2
  46. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  47. package/src/channels/shared/inbound-ttl.mjs +22 -0
@@ -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, 'turn-'));
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
+ }