@xgjktech/xg_cwork_im 1.0.8 → 1.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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * 入站附件:可选落盘到当前 Agent workspace 下指定子目录(相对路径)。
3
+ *
4
+ * 目录约定:{subdir}/{userId}/{YYYY-MM-DD}/{stem}_{HHmmssmmm}{ext}
5
+ * 时间取写入时的本地时区。
6
+ */
7
+
8
+ import { mkdir, writeFile } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
12
+ import type { Log } from "./auth.js";
13
+ import { downloadMediaBuffer, resolveMaxAttachmentBytes } from "./resource-file.js";
14
+ import type { MsgFileVO, XgImConfig } from "./types.js";
15
+
16
+ /** 校验:非空、相对路径、不含 `..` 段 */
17
+ export function normalizeInboundWorkspaceSubdir(subdir: string): string | null {
18
+ const t = subdir.trim().replace(/\\/g, "/");
19
+ if (!t || t === "." || t === "..") return null;
20
+ const parts = t.split("/").filter(Boolean);
21
+ for (const p of parts) {
22
+ if (p === "..") return null;
23
+ }
24
+ if (path.isAbsolute(t)) return null;
25
+ return parts.join("/");
26
+ }
27
+
28
+ export function resolveAgentWorkspaceDirFromRuntime(rt: unknown, cfg: OpenClawConfig): string | undefined {
29
+ const agent = (rt as { agent?: { resolveAgentWorkspaceDir?: (c: OpenClawConfig) => unknown } })?.agent;
30
+ const fn = agent?.resolveAgentWorkspaceDir;
31
+ if (typeof fn !== "function") return undefined;
32
+ try {
33
+ const out = fn(cfg);
34
+ if (typeof out === "string" && out.trim()) return path.resolve(out.trim());
35
+ } catch {
36
+ /* ignore */
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ export async function ensureAgentWorkspaceFromRuntime(rt: unknown, cfg: OpenClawConfig): Promise<void> {
42
+ const agent = (rt as { agent?: { ensureAgentWorkspace?: (c: OpenClawConfig) => Promise<void> } })?.agent;
43
+ const fn = agent?.ensureAgentWorkspace;
44
+ if (typeof fn !== "function") return;
45
+ try {
46
+ await fn(cfg);
47
+ } catch {
48
+ /* 落盘前仍会 mkdir,不阻断 */
49
+ }
50
+ }
51
+
52
+ function sanitizeUserIdForPath(id: string): string {
53
+ const t = id.trim();
54
+ if (/^-?\d+$/.test(t) && t.length <= 32) return t;
55
+ const safe = t.replace(/[^\w.-]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
56
+ return safe.slice(0, 64) || "unknown";
57
+ }
58
+
59
+ function formatDateFolderLocal(d: Date): string {
60
+ const y = d.getFullYear();
61
+ const m = String(d.getMonth() + 1).padStart(2, "0");
62
+ const day = String(d.getDate()).padStart(2, "0");
63
+ return `${y}-${m}-${day}`;
64
+ }
65
+
66
+ /** `HHmmss` + 三位毫秒,如 `120459237` */
67
+ export function formatInboundTimeSuffix(d: Date): string {
68
+ const h = String(d.getHours()).padStart(2, "0");
69
+ const min = String(d.getMinutes()).padStart(2, "0");
70
+ const s = String(d.getSeconds()).padStart(2, "0");
71
+ const ms = String(d.getMilliseconds()).padStart(3, "0");
72
+ return `${h}${min}${s}${ms}`;
73
+ }
74
+
75
+ const MAX_STEM_LEN = 120;
76
+
77
+ function sanitizeStem(stem: string): string {
78
+ const s = stem
79
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
80
+ .replace(/_+/g, "_")
81
+ .replace(/^\.+|\.+$/g, "")
82
+ .trim();
83
+ const out = s || "file";
84
+ return out.length > MAX_STEM_LEN ? out.slice(0, MAX_STEM_LEN) : out;
85
+ }
86
+
87
+ /**
88
+ * 将落盘绝对路径转为 OpenClaw / 沙箱可用的 workspace 相对引用(POSIX 风格,带 `./` 前缀)。
89
+ * 若文件不在 workspaceRoot 之下则返回 null(调用方应回退 file:// 或报错)。
90
+ */
91
+ export function absPathToWorkspaceRelativeOpenClawRef(workspaceRoot: string, absPath: string): string | null {
92
+ const root = path.resolve(workspaceRoot.trim());
93
+ const abs = path.resolve(absPath);
94
+ const rel = path.relative(root, abs);
95
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
96
+ const piece = rel.length > 0 ? rel : path.basename(abs);
97
+ const posix = piece.split(path.sep).join("/");
98
+ return posix.startsWith("./") ? posix : `./${posix}`;
99
+ }
100
+
101
+ function openClawMediaRefForSavedFile(args: {
102
+ workspaceRoot: string;
103
+ absPath: string;
104
+ style: "workspaceRelative" | "absoluteFileUrl" | undefined;
105
+ log?: Log;
106
+ }): string {
107
+ const { workspaceRoot, absPath, style, log } = args;
108
+ if (style === "absoluteFileUrl") {
109
+ return pathToFileURL(absPath).href;
110
+ }
111
+ const rel = absPathToWorkspaceRelativeOpenClawRef(workspaceRoot, absPath);
112
+ if (rel) return rel;
113
+ log?.warn?.(
114
+ `[cwork_im:inbound-local] file not under workspace root, fallback to file:// absPath=${absPath.slice(0, 120)}`,
115
+ );
116
+ return pathToFileURL(absPath).href;
117
+ }
118
+
119
+ /** `report_120459237.pdf` */
120
+ export function buildInboundStoredFilename(originalFilename: string, at: Date): string {
121
+ const base = path.basename(originalFilename) || "file";
122
+ const ext = path.extname(base);
123
+ const stem = ext ? base.slice(0, -ext.length) : base;
124
+ const suffix = formatInboundTimeSuffix(at);
125
+ return `${sanitizeStem(stem)}_${suffix}${ext.toLowerCase()}`;
126
+ }
127
+
128
+ /**
129
+ * 将入站附件下载到 workspace 下子目录;失败项保留原 URL。
130
+ */
131
+ export async function saveInboundFilesToWorkspace(args: {
132
+ rt: unknown;
133
+ cfg: OpenClawConfig;
134
+ config: XgImConfig;
135
+ workspaceSubdir: string;
136
+ senderUserId: string | undefined;
137
+ fileItems: MsgFileVO[];
138
+ log?: Log;
139
+ }): Promise<MsgFileVO[]> {
140
+ const { rt, cfg, config, workspaceSubdir, senderUserId, fileItems, log } = args;
141
+ const normalized = normalizeInboundWorkspaceSubdir(workspaceSubdir);
142
+ if (!normalized || fileItems.length === 0) return fileItems;
143
+
144
+ const workspaceRoot = resolveAgentWorkspaceDirFromRuntime(rt, cfg);
145
+ if (!workspaceRoot) {
146
+ log?.warn?.(
147
+ "[cwork_im:inbound-local] resolveAgentWorkspaceDir unavailable, skip inbound download; keep remote URLs",
148
+ );
149
+ return fileItems;
150
+ }
151
+
152
+ await ensureAgentWorkspaceFromRuntime(rt, cfg);
153
+
154
+ const maxBytes = resolveMaxAttachmentBytes(config);
155
+ const userSeg = sanitizeUserIdForPath(senderUserId ?? "unknown");
156
+ const relParts = normalized.split("/");
157
+
158
+ const out: MsgFileVO[] = [];
159
+ for (const item of fileItems) {
160
+ const srcUrl = item.url?.trim();
161
+ if (!srcUrl) {
162
+ out.push(item);
163
+ continue;
164
+ }
165
+ const now = new Date();
166
+ const dateFolder = formatDateFolderLocal(now);
167
+ const localName = buildInboundStoredFilename(item.name?.trim() || "file", now);
168
+ const dir = path.join(workspaceRoot, ...relParts, userSeg, dateFolder);
169
+ const absPath = path.join(dir, localName);
170
+
171
+ try {
172
+ await mkdir(dir, { recursive: true });
173
+ const { buffer } = await downloadMediaBuffer(srcUrl, maxBytes, log);
174
+ await writeFile(absPath, buffer);
175
+ const pathStyle = config.inboundMediaOpenClawPath ?? "workspaceRelative";
176
+ const mediaRef = openClawMediaRefForSavedFile({
177
+ workspaceRoot,
178
+ absPath,
179
+ style: pathStyle,
180
+ log,
181
+ });
182
+ log?.info?.(
183
+ `[cwork_im:inbound-local] saved ${localName} bytes=${buffer.length} abs=${absPath} openclawRef=${mediaRef.slice(0, 120)}`,
184
+ );
185
+ out.push({
186
+ ...item,
187
+ url: mediaRef,
188
+ size: buffer.length,
189
+ name: localName,
190
+ });
191
+ } catch (e: unknown) {
192
+ log?.warn?.(
193
+ `[cwork_im:inbound-local] download/save failed, keep original URL: ${String(e)} ref=${srcUrl.slice(0, 80)}`,
194
+ );
195
+ out.push(item);
196
+ }
197
+ }
198
+ return out;
199
+ }
package/src/types.ts CHANGED
@@ -40,6 +40,14 @@ export interface XgImAccountConfig {
40
40
  /** multipart 整文件上传字段名,默认 file(与 `/file/upDownload/uploadWholeFile` 约定一致时可不改) */
41
41
  fileUploadFormField?: string;
42
42
  maxAttachmentBytes?: number;
43
+ /** 覆盖顶层的入站附件 workspace 子目录(相对 Agent workspace) */
44
+ inboundMediaWorkspaceSubdir?: string;
45
+ /**
46
+ * 覆盖顶层:入站落盘后交给 OpenClaw 的 `MediaPath` 形式。
47
+ * - `workspaceRelative`(默认):`./xg_im_inbound/...`,沙箱内 `/workspace` 可解析。
48
+ * - `absoluteFileUrl`:宿主机 `file://...`,仅适合无沙箱、同进程读盘场景。
49
+ */
50
+ inboundMediaOpenClawPath?: "workspaceRelative" | "absoluteFileUrl";
43
51
  }
44
52
 
45
53
  export interface XgImConfig extends OpenClawConfig {
@@ -87,6 +95,18 @@ export interface XgImConfig extends OpenClawConfig {
87
95
  fileUploadFormField?: string;
88
96
  /** 单个附件下载/上传允许的最大字节数,默认 50MB */
89
97
  maxAttachmentBytes?: number;
98
+
99
+ /**
100
+ * 若配置为非空相对路径(相对于当前 Agent workspace 根目录):入站附件会先下载到
101
+ * `{workspace}/{本字段}/{userId}/{YYYY-MM-DD}/{name}_{HHmmssmmm}.ext`,再向 OpenClaw 传入 **workspace 相对路径**(默认 `./子目录/...`,便于沙箱挂载 `/workspace`)或 `file://`(见 `inboundMediaOpenClawPath`)。
102
+ * 未配置或为空则仍使用 IM 返回的远程 URL。
103
+ */
104
+ inboundMediaWorkspaceSubdir?: string;
105
+ /**
106
+ * 入站落盘后写入 `MediaPath` 的引用形式;默认与 `workspaceRelative` 等价(未填时)。
107
+ * Agent 在沙箱中运行时应用 `workspaceRelative`,避免宿主机绝对 `file://` 在沙箱内不可读。
108
+ */
109
+ inboundMediaOpenClawPath?: "workspaceRelative" | "absoluteFileUrl";
90
110
  }
91
111
 
92
112
  // ─── IM 接口 Request / Response ─────────────────────────────────────────────
@@ -128,7 +148,7 @@ export interface WsMessage {
128
148
  * 与 IM 服务 `MsgFileVO` 对齐的附件项(WebSocket `msgContent.files[]`)。
129
149
  */
130
150
  export interface MsgFileVO {
131
- /** 文件下载链接,短期有效 */
151
+ /** 远程下载链接,或入站落盘后的 `file://` / workspace 相对引用(如 `./xg_im_inbound/...`) */
132
152
  url: string;
133
153
  /** 文件格式,小写:pdf、png、docx、doc、ppt、txt、md 等 */
134
154
  format?: string;
@@ -138,6 +158,11 @@ export interface MsgFileVO {
138
158
  size?: number;
139
159
  /** 文件名称,如 xxx.pdf */
140
160
  name?: string;
161
+ /**
162
+ * IM 侧已解析的正文(如图片 OCR/说明、文档抽取的文本等)。
163
+ * 非空时插件会拼入入站 `Body`/`RawBody`,一并交给 OpenClaw。
164
+ */
165
+ content?: string;
141
166
  }
142
167
 
143
168
  /** @deprecated 使用 {@link MsgFileVO} */
@@ -175,7 +200,7 @@ export function imFormatToMimeType(format: string | undefined): string | undefin
175
200
  * 入站消息内容体(与 IM WebSocket `params.msgContent` 对齐)。
176
201
  *
177
202
  * - **type=`text`**:纯文本;**text** 建议非空。
178
- * - **type=`file`**:带 1..N 个文件,**files** 建议至少 1 项;**text** 可为配文(同时上传多文件 + 一句话)。
203
+ * - **type=`file`**:带 1..N 个文件,**files** 建议至少 1 项;**text** 可为配文(同时上传多文件 + 一句话)。**files[].content** 可由 IM 填已解析文本(如图/文档),插件会拼入入站正文。
179
204
  * - 语音在 IM 侧已转写为文字时,应以 **type=`text`** 推送转写结果,不再使用 voice。
180
205
  */
181
206
  export interface WsMessageContent {