@openclaw-china/shared 0.1.26 → 0.1.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-china/shared",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -0,0 +1,115 @@
1
+ const CRON_HIDDEN_PROMPT = `
2
+ When creating or updating a cron task, use the main session's last route as the delivery target.
3
+ - Do not ask the user for userId/chatId
4
+ - sessionTarget="isolated"
5
+ - payload.kind="agentTurn"
6
+ - payload.deliver=true
7
+ - Do not set payload.channel/to (fall back to last route automatically)
8
+ - Do not call the message tool to send`;
9
+
10
+ const CRON_TRIGGER_KEYWORDS = [
11
+ "定时",
12
+ "提醒",
13
+ "每分钟",
14
+ "每小时",
15
+ "每天",
16
+ "每周",
17
+ "几点",
18
+ "早上",
19
+ "晚上",
20
+ "工作日",
21
+ "cron",
22
+ "remind",
23
+ "reminder",
24
+ "schedule",
25
+ "scheduled",
26
+ "every minute",
27
+ "every hour",
28
+ "every day",
29
+ "daily",
30
+ "every week",
31
+ "weekly",
32
+ "weekday",
33
+ "workday",
34
+ "morning",
35
+ "evening",
36
+ ];
37
+
38
+ const CRON_TRIGGER_PATTERNS = [
39
+ /提醒我/u,
40
+ /帮我定时/u,
41
+ /每.+提醒/u,
42
+ /每天.+发/u,
43
+ /remind me/iu,
44
+ /set (a )?reminder/iu,
45
+ /every .+ remind/iu,
46
+ /every day .+ (send|post|notify)/iu,
47
+ /schedule .+ (reminder|message|notification)/iu,
48
+ ];
49
+
50
+ const CRON_EXCLUDE_PATTERNS = [
51
+ /是什么意思/u,
52
+ /区别/u,
53
+ /为什么/u,
54
+ /\bhelp\b/iu,
55
+ /文档/u,
56
+ /怎么用/u,
57
+ /what does|what's|meaning of/iu,
58
+ /difference/iu,
59
+ /why/iu,
60
+ /\bdocs?\b/iu,
61
+ /documentation/iu,
62
+ /how to/iu,
63
+ /usage/iu,
64
+ ];
65
+
66
+ export function shouldInjectCronHiddenPrompt(text: string): boolean {
67
+ const normalized = text.trim();
68
+ if (!normalized) return false;
69
+ const lowered = normalized.toLowerCase();
70
+
71
+ for (const pattern of CRON_EXCLUDE_PATTERNS) {
72
+ if (pattern.test(lowered)) return false;
73
+ }
74
+
75
+ for (const keyword of CRON_TRIGGER_KEYWORDS) {
76
+ if (lowered.includes(keyword.toLowerCase())) return true;
77
+ }
78
+
79
+ return CRON_TRIGGER_PATTERNS.some((pattern) => pattern.test(normalized));
80
+ }
81
+
82
+ export function splitCronHiddenPrompt(text: string): { base: string; prompt?: string } {
83
+ const idx = text.indexOf(CRON_HIDDEN_PROMPT);
84
+ if (idx === -1) {
85
+ return { base: text };
86
+ }
87
+ const base = text.slice(0, idx).trimEnd();
88
+ return { base, prompt: CRON_HIDDEN_PROMPT };
89
+ }
90
+
91
+ export function appendCronHiddenPrompt(text: string): string {
92
+ if (!shouldInjectCronHiddenPrompt(text)) return text;
93
+ if (text.includes(CRON_HIDDEN_PROMPT)) return text;
94
+ return `${text}\n\n${CRON_HIDDEN_PROMPT}`;
95
+ }
96
+
97
+ export function applyCronHiddenPromptToContext<
98
+ T extends { Body?: string; RawBody?: string; CommandBody?: string }
99
+ >(ctx: T): boolean {
100
+ const base =
101
+ (typeof ctx.RawBody === "string" && ctx.RawBody) ||
102
+ (typeof ctx.Body === "string" && ctx.Body) ||
103
+ (typeof ctx.CommandBody === "string" && ctx.CommandBody) ||
104
+ "";
105
+
106
+ if (!base) return false;
107
+
108
+ const next = appendCronHiddenPrompt(base);
109
+ if (next === base) return false;
110
+
111
+ ctx.CommandBody = next;
112
+ return true;
113
+ }
114
+
115
+ export { CRON_HIDDEN_PROMPT };
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- // @openclaw-china/shared
2
- // 共享工具模块
3
-
4
- export * from "./logger/index.js";
5
- export * from "./policy/index.js";
6
- export * from "./http/index.js";
7
- export * from "./types/common.js";
8
- export * from "./file/index.js";
9
- export * from "./media/index.js";
1
+ // @openclaw-china/shared
2
+ // 共享工具模块
3
+
4
+ export * from "./logger/index.js";
5
+ export * from "./policy/index.js";
6
+ export * from "./http/index.js";
7
+ export * from "./types/common.js";
8
+ export * from "./file/index.js";
9
+ export * from "./media/index.js";
10
+ export * from "./cron/index.js";
@@ -1,57 +1,65 @@
1
- /**
2
- * 媒体处理模块
3
- *
4
- * 提供统一的媒体解析、路径处理和文件读取功能
5
- *
6
- * @module @openclaw-china/shared/media
7
- */
8
-
9
- // 媒体解析
10
- export {
11
- // 类型
12
- type MediaType,
13
- type ExtractedMedia,
14
- type MediaParseResult,
15
- type MediaParseOptions,
16
- // 常量
17
- IMAGE_EXTENSIONS,
18
- AUDIO_EXTENSIONS,
19
- VIDEO_EXTENSIONS,
20
- NON_IMAGE_EXTENSIONS,
21
- // 路径处理函数
22
- isHttpUrl,
23
- isFileUrl,
24
- isLocalReference,
25
- normalizeLocalPath,
26
- stripTitleFromUrl,
27
- getExtension,
28
- isImagePath,
29
- isNonImageFilePath,
30
- detectMediaType,
31
- // 媒体提取函数
32
- extractMediaFromText,
33
- extractImagesFromText,
34
- extractFilesFromText,
35
- } from "./media-parser.js";
36
-
37
- // 媒体 IO
1
+ /**
2
+ * 媒体处理模块
3
+ *
4
+ * 提供统一的媒体解析、路径处理和文件读取功能
5
+ *
6
+ * @module @openclaw-china/shared/media
7
+ */
8
+
9
+ // 媒体解析
10
+ export {
11
+ // 类型
12
+ type MediaType,
13
+ type ExtractedMedia,
14
+ type MediaParseResult,
15
+ type MediaParseOptions,
16
+ // 常量
17
+ IMAGE_EXTENSIONS,
18
+ AUDIO_EXTENSIONS,
19
+ VIDEO_EXTENSIONS,
20
+ NON_IMAGE_EXTENSIONS,
21
+ // 路径处理函数
22
+ isHttpUrl,
23
+ isFileUrl,
24
+ isLocalReference,
25
+ normalizeLocalPath,
26
+ stripTitleFromUrl,
27
+ getExtension,
28
+ isImagePath,
29
+ isNonImageFilePath,
30
+ detectMediaType,
31
+ // 媒体提取函数
32
+ extractMediaFromText,
33
+ extractImagesFromText,
34
+ extractFilesFromText,
35
+ } from "./media-parser.js";
36
+
37
+ // 媒体 IO
38
38
  export {
39
39
  // 类型
40
40
  type MediaReadResult,
41
41
  type MediaReadOptions,
42
+ type DownloadToTempFileResult,
43
+ type DownloadToTempFileOptions,
44
+ type FinalizeInboundMediaOptions,
45
+ type PruneInboundMediaDirOptions,
42
46
  type PathSecurityOptions,
43
47
  // 错误类
44
48
  FileSizeLimitError,
45
49
  MediaTimeoutError,
46
50
  PathSecurityError,
47
- // 路径安全
48
- validatePathSecurity,
49
- getDefaultAllowedPrefixes,
50
- // MIME 类型
51
- getMimeType,
52
- // 媒体读取函数
51
+ // 路径安全
52
+ validatePathSecurity,
53
+ getDefaultAllowedPrefixes,
54
+ // MIME 类型
55
+ getMimeType,
56
+ // 媒体读取函数
53
57
  fetchMediaFromUrl,
54
58
  readMediaFromLocal,
55
59
  readMedia,
56
60
  readMediaBatch,
61
+ downloadToTempFile,
62
+ finalizeInboundMediaFile,
63
+ pruneInboundMediaDir,
64
+ cleanupFileSafe,
57
65
  } from "./media-io.js";
@@ -0,0 +1,180 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as fsPromises from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import {
7
+ FileSizeLimitError,
8
+ MediaTimeoutError,
9
+ cleanupFileSafe,
10
+ downloadToTempFile,
11
+ finalizeInboundMediaFile,
12
+ pruneInboundMediaDir,
13
+ } from "./media-io.js";
14
+
15
+ const tempDirs: string[] = [];
16
+
17
+ async function createTempDir(prefix: string): Promise<string> {
18
+ const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), prefix));
19
+ tempDirs.push(dir);
20
+ return dir;
21
+ }
22
+
23
+ afterEach(async () => {
24
+ for (const dir of tempDirs.splice(0, tempDirs.length)) {
25
+ await fsPromises.rm(dir, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ describe("downloadToTempFile", () => {
30
+ it("downloads HTTP response and stores a temp file", async () => {
31
+ const dir = await createTempDir("shared-media-io-");
32
+ const body = Buffer.from("hello-media", "utf8");
33
+ const fetchFn: typeof globalThis.fetch = async () =>
34
+ new Response(body, {
35
+ status: 200,
36
+ headers: {
37
+ "content-type": "image/png",
38
+ "content-length": String(body.length),
39
+ },
40
+ });
41
+
42
+ const result = await downloadToTempFile("https://example.com/a.png", {
43
+ fetch: fetchFn,
44
+ tempDir: dir,
45
+ tempPrefix: "dingtalk-file",
46
+ });
47
+
48
+ expect(result.path.startsWith(dir)).toBe(true);
49
+ expect(result.fileName.endsWith(".png")).toBe(true);
50
+ expect(result.size).toBe(body.length);
51
+ expect(result.contentType).toBe("image/png");
52
+
53
+ const saved = await fsPromises.readFile(result.path);
54
+ expect(saved.equals(body)).toBe(true);
55
+ });
56
+
57
+ it("throws FileSizeLimitError when Content-Length exceeds maxSize", async () => {
58
+ const fetchFn: typeof globalThis.fetch = async () =>
59
+ new Response("too-large", {
60
+ status: 200,
61
+ headers: {
62
+ "content-type": "application/octet-stream",
63
+ "content-length": "1024",
64
+ },
65
+ });
66
+
67
+ await expect(
68
+ downloadToTempFile("https://example.com/too-large.bin", {
69
+ fetch: fetchFn,
70
+ maxSize: 100,
71
+ })
72
+ ).rejects.toBeInstanceOf(FileSizeLimitError);
73
+ });
74
+
75
+ it("throws MediaTimeoutError on timeout", async () => {
76
+ const fetchFn: typeof globalThis.fetch = async (_url, init) =>
77
+ await new Promise<Response>((_resolve, reject) => {
78
+ const signal = init?.signal;
79
+ signal?.addEventListener("abort", () => {
80
+ const err = new Error("aborted");
81
+ (err as Error & { name: string }).name = "AbortError";
82
+ reject(err);
83
+ });
84
+ });
85
+
86
+ await expect(
87
+ downloadToTempFile("https://example.com/slow.bin", {
88
+ fetch: fetchFn,
89
+ timeout: 10,
90
+ })
91
+ ).rejects.toBeInstanceOf(MediaTimeoutError);
92
+ });
93
+ });
94
+
95
+ describe("cleanupFileSafe", () => {
96
+ it("removes file and ignores missing file", async () => {
97
+ const dir = await createTempDir("shared-media-clean-");
98
+ const filePath = path.join(dir, "a.txt");
99
+ await fsPromises.writeFile(filePath, "x", "utf8");
100
+ expect(fs.existsSync(filePath)).toBe(true);
101
+
102
+ await cleanupFileSafe(filePath);
103
+ expect(fs.existsSync(filePath)).toBe(false);
104
+
105
+ await expect(cleanupFileSafe(filePath)).resolves.toBeUndefined();
106
+ await expect(cleanupFileSafe(undefined)).resolves.toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe("inbound media retention", () => {
111
+ it("finalizes temp media into inbound/YYYY-MM-DD", async () => {
112
+ const tempDir = await createTempDir("shared-media-temp-");
113
+ const inboundDir = await createTempDir("shared-media-inbound-");
114
+ const sourcePath = path.join(tempDir, "img-1.jpg");
115
+ await fsPromises.writeFile(sourcePath, "abc", "utf8");
116
+
117
+ const finalPath = await finalizeInboundMediaFile({
118
+ filePath: sourcePath,
119
+ tempDir,
120
+ inboundDir,
121
+ });
122
+
123
+ expect(finalPath.startsWith(inboundDir)).toBe(true);
124
+ expect(fs.existsSync(finalPath)).toBe(true);
125
+ expect(fs.existsSync(sourcePath)).toBe(false);
126
+ });
127
+
128
+ it("does not move files outside tempDir", async () => {
129
+ const tempDir = await createTempDir("shared-media-temp-");
130
+ const inboundDir = await createTempDir("shared-media-inbound-");
131
+ const outsideDir = await createTempDir("shared-media-outside-");
132
+ const sourcePath = path.join(outsideDir, "a.txt");
133
+ await fsPromises.writeFile(sourcePath, "x", "utf8");
134
+
135
+ const finalPath = await finalizeInboundMediaFile({
136
+ filePath: sourcePath,
137
+ tempDir,
138
+ inboundDir,
139
+ });
140
+
141
+ expect(finalPath).toBe(sourcePath);
142
+ expect(fs.existsSync(sourcePath)).toBe(true);
143
+ });
144
+
145
+ it("prunes only expired files in date dirs and keeps recent files", async () => {
146
+ const inboundDir = await createTempDir("shared-media-prune-");
147
+ const oldDir = path.join(inboundDir, "2024-01-01");
148
+ const newDir = path.join(inboundDir, "2024-01-02");
149
+ await fsPromises.mkdir(oldDir, { recursive: true });
150
+ await fsPromises.mkdir(newDir, { recursive: true });
151
+
152
+ const oldFile = path.join(oldDir, "old.jpg");
153
+ const newFile = path.join(newDir, "new.jpg");
154
+ const nestedDir = path.join(oldDir, "nested");
155
+ const nestedFile = path.join(nestedDir, "nested.jpg");
156
+ await fsPromises.writeFile(oldFile, "old", "utf8");
157
+ await fsPromises.writeFile(newFile, "new", "utf8");
158
+ await fsPromises.mkdir(nestedDir, { recursive: true });
159
+ await fsPromises.writeFile(nestedFile, "nested", "utf8");
160
+
161
+ const oldTs = new Date("2024-01-01T00:00:00.000Z");
162
+ const newTs = new Date("2024-01-02T00:00:00.000Z");
163
+ await fsPromises.utimes(oldDir, oldTs, oldTs);
164
+ await fsPromises.utimes(oldFile, oldTs, oldTs);
165
+ await fsPromises.utimes(newDir, newTs, newTs);
166
+ await fsPromises.utimes(newFile, newTs, newTs);
167
+
168
+ const nowMs = new Date("2024-01-03T00:00:00.000Z").getTime();
169
+ await pruneInboundMediaDir({
170
+ inboundDir,
171
+ keepDays: 1,
172
+ nowMs,
173
+ });
174
+
175
+ expect(fs.existsSync(oldFile)).toBe(false);
176
+ expect(fs.existsSync(newFile)).toBe(true);
177
+ expect(fs.existsSync(nestedFile)).toBe(true);
178
+ expect(fs.existsSync(oldDir)).toBe(true);
179
+ });
180
+ });