@dcrays/dcgchat-test 0.4.27 → 0.4.29

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": "@dcrays/dcgchat-test",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/channel.ts CHANGED
@@ -192,6 +192,14 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
192
192
  return
193
193
  }
194
194
  const mediaUrl = expanded[0]
195
+
196
+ const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
197
+ if (!msgCtx.sessionId) {
198
+ msgCtx.sessionId = sessionId
199
+ }
200
+ if (!msgCtx.agentId) {
201
+ msgCtx.agentId = agentId
202
+ }
195
203
  if (!mediaUrl || !msgCtx.sessionId) {
196
204
  dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
197
205
  return
@@ -213,19 +221,11 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
213
221
  }
214
222
  addSentMediaKey(msgCtx.sessionId, mediaUrl)
215
223
  }
216
-
217
- const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
218
- if (!msgCtx.sessionId) {
219
- msgCtx.sessionId = sessionId
220
- }
221
224
  const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
222
225
  const notMessageId = `${msgCtx?.messageId}`?.length === 13 || !msgCtx?.messageId
223
226
  try {
224
227
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat-test"]?.botToken ?? ''
225
- const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
226
- if (!msgCtx.agentId) {
227
- msgCtx.agentId = agentId
228
- }
228
+ const url = mediaUrl ? await ossUpload(mediaUrl, botToken, 1) : ''
229
229
  wsSendRaw(msgCtx, {
230
230
  response: opts.text ?? '',
231
231
  is_finish: notMessageId ? -1 : 0,
@@ -1,32 +1,166 @@
1
- import { createReadStream } from 'node:fs'
1
+ import { stat } from 'node:fs/promises'
2
+ import { extname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
2
4
  // @ts-ignore
3
5
  import OSS from 'ali-oss'
4
6
  import { getStsToken, getUserToken } from './api.js'
5
7
  import { dcgLogger } from '../utils/log.js'
6
8
 
7
- /** File/路径/Buffer 转为 ali-oss put 所需的 Buffer 或 ReadableStream */
8
- async function toUploadContent(
9
- input: File | string | Buffer
10
- ): Promise<{ content: Buffer | ReturnType<typeof createReadStream>; fileName: string }> {
9
+ /** 仅对内存 Buffer 超过此大小使用分片(本地路径一律走 put,避免 multipart 对类型的限制) */
10
+ const MULTIPART_THRESHOLD_BYTES = 1024 * 1024
11
+
12
+ /** 分片大小:OSS 要求每片 ≥100 KB(最后一片可更小) */
13
+ const MULTIPART_PART_SIZE = 1024 * 1024
14
+
15
+ /** ali-oss 默认 timeout 为 60s,大文件单 PUT 或慢网易触发 ResponseTimeoutError */
16
+ const OSS_HTTP_TIMEOUT_MS = 15 * 60 * 1000
17
+
18
+ /** 归一化入参,避免 file://、包装对象、TypedArray 等导致 SDK 识别失败 */
19
+ function coerceOssFileInput(input: File | string | Buffer): File | string | Buffer {
20
+ if (typeof input === 'string') {
21
+ const t = input.trim()
22
+ if (t.startsWith('file:')) {
23
+ try {
24
+ return fileURLToPath(t)
25
+ } catch {
26
+ return input
27
+ }
28
+ }
29
+ return input
30
+ }
31
+ if (Buffer.isBuffer(input)) {
32
+ return input
33
+ }
34
+ if (input && typeof input === 'object') {
35
+ if (ArrayBuffer.isView(input) && !(input instanceof DataView) && !Buffer.isBuffer(input)) {
36
+ const v = input as ArrayBufferView
37
+ return Buffer.from(v.buffer, v.byteOffset, v.byteLength)
38
+ }
39
+ const o = input as unknown as Record<string, unknown>
40
+ const p = o.path ?? o.filePath
41
+ if (typeof p === 'string' && p.trim()) return p.trim()
42
+ }
43
+ return input
44
+ }
45
+
46
+ async function getUploadByteLength(input: File | string | Buffer): Promise<number> {
47
+ if (Buffer.isBuffer(input)) return input.length
48
+ if (typeof input === 'string') {
49
+ const s = await stat(input)
50
+ return s.size
51
+ }
52
+ return input.size
53
+ }
54
+
55
+ /** 常见可在浏览器内联预览的类型(避免一律 application/octet-stream 触发下载) */
56
+ const PREVIEW_EXT_MIME: Record<string, string> = {
57
+ '.jpg': 'image/jpeg',
58
+ '.jpeg': 'image/jpeg',
59
+ '.png': 'image/png',
60
+ '.gif': 'image/gif',
61
+ '.webp': 'image/webp',
62
+ '.bmp': 'image/bmp',
63
+ '.svg': 'image/svg+xml',
64
+ '.ico': 'image/x-icon',
65
+ '.avif': 'image/avif',
66
+ '.heic': 'image/heic',
67
+ '.heif': 'image/heif',
68
+ '.pdf': 'application/pdf',
69
+ '.mp4': 'video/mp4',
70
+ '.webm': 'video/webm',
71
+ '.mov': 'video/quicktime',
72
+ '.mp3': 'audio/mpeg',
73
+ '.wav': 'audio/wav',
74
+ '.ogg': 'audio/ogg',
75
+ '.opus': 'audio/opus',
76
+ '.m4a': 'audio/mp4',
77
+ '.aac': 'audio/aac',
78
+ '.flac': 'audio/flac',
79
+ '.txt': 'text/plain; charset=utf-8',
80
+ '.log': 'text/plain; charset=utf-8',
81
+ '.csv': 'text/csv; charset=utf-8',
82
+ '.html': 'text/html; charset=utf-8',
83
+ '.htm': 'text/html; charset=utf-8',
84
+ '.css': 'text/css; charset=utf-8',
85
+ '.js': 'text/javascript; charset=utf-8',
86
+ '.mjs': 'text/javascript; charset=utf-8',
87
+ '.json': 'application/json; charset=utf-8',
88
+ '.xml': 'application/xml; charset=utf-8',
89
+ '.md': 'text/markdown; charset=utf-8',
90
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
91
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
92
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
93
+ }
94
+
95
+ function mimeFromPathOrName(pathOrName: string): string | undefined {
96
+ const base = pathOrName.split(/[/\\]/).pop() ?? pathOrName
97
+ const ext = extname(base).toLowerCase()
98
+ return ext ? PREVIEW_EXT_MIME[ext] : undefined
99
+ }
100
+
101
+ /** 解析上传 Content-Type,并配合 Content-Disposition: inline 便于直链预览 */
102
+ function resolveMime(input: File | string | Buffer, fileNameHint?: string): string {
103
+ if (typeof input === 'string') {
104
+ return mimeFromPathOrName(input) ?? 'application/octet-stream'
105
+ }
106
+ if (Buffer.isBuffer(input)) {
107
+ if (fileNameHint) {
108
+ const fromName = mimeFromPathOrName(fileNameHint)
109
+ if (fromName) return fromName
110
+ }
111
+ return 'application/octet-stream'
112
+ }
113
+ const declared = input.type?.trim()
114
+ if (declared && declared !== 'application/octet-stream') {
115
+ return declared
116
+ }
117
+ const name = typeof input.name === 'string' && input.name ? input.name : ''
118
+ if (name) {
119
+ const fromName = mimeFromPathOrName(name)
120
+ if (fromName) return fromName
121
+ }
122
+ return declared || 'application/octet-stream'
123
+ }
124
+
125
+ /**
126
+ * 将 File/路径/Buffer 转为 ali-oss 接受的类型。
127
+ * 本地路径保持为字符串:put 内部用 contentLength + ReadStream,大文件也稳定。
128
+ */
129
+ async function toUploadContent(input: File | string | Buffer): Promise<{ content: Buffer | string; fileName: string }> {
11
130
  if (Buffer.isBuffer(input)) {
12
131
  return { content: input, fileName: 'file' }
13
132
  }
14
133
  if (typeof input === 'string') {
15
134
  return {
16
- content: createReadStream(input),
17
- fileName: input.split('/').pop() ?? 'file'
135
+ content: input,
136
+ fileName: input.split(/[/\\]/).pop() ?? 'file'
18
137
  }
19
138
  }
20
- // File: ali-oss 需要 Buffer/Stream,用 arrayBuffer 转 Buffer
21
139
  const buf = Buffer.from(await input.arrayBuffer())
22
- return { content: buf, fileName: input.name }
140
+ const n = (input as { name?: string }).name
141
+ return { content: buf, fileName: typeof n === 'string' && n ? n : 'file' }
142
+ }
143
+
144
+ export type OssUploadOptions = {
145
+ /** 分片上传进度,p 为 0~1(仅大 Buffer 分片时触发) */
146
+ onProgress?: (p: number) => void
147
+ /** HTTP 超时(毫秒),覆盖默认 15 分钟;可传 `30 * 60 * 1000` 等 */
148
+ timeoutMs?: number
23
149
  }
24
150
 
25
- export const ossUpload = async (file: File | string | Buffer, botToken: string, isPrivate: 0 | 1 = 1) => {
151
+ export const ossUpload = async (
152
+ rawFile: File | string | Buffer,
153
+ botToken: string,
154
+ isPrivate: 0 | 1 = 1,
155
+ uploadOptions?: OssUploadOptions
156
+ ) => {
26
157
  await getUserToken(botToken)
27
158
 
159
+ const file = coerceOssFileInput(rawFile)
28
160
  const { content, fileName } = await toUploadContent(file)
29
161
  const data = await getStsToken(fileName, botToken, isPrivate)
162
+ const mime = resolveMime(file, fileName)
163
+ const onProgress = uploadOptions?.onProgress
30
164
 
31
165
  const options: OSS.Options = {
32
166
  // 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
@@ -40,7 +174,8 @@ export const ossUpload = async (file: File | string | Buffer, botToken: string,
40
174
  region: data.region,
41
175
  secure: true,
42
176
  cname: true,
43
- authorizationV4: true
177
+ authorizationV4: true,
178
+ timeout: uploadOptions?.timeoutMs ?? OSS_HTTP_TIMEOUT_MS
44
179
  }
45
180
 
46
181
  const client = new OSS(options)
@@ -48,13 +183,29 @@ export const ossUpload = async (file: File | string | Buffer, botToken: string,
48
183
  const name = `${data.uploadDir}${data.ossFileKey}`
49
184
 
50
185
  try {
51
- const objectResult = await client.put(name, content)
186
+ let objectResult: OSS.PutObjectResult | OSS.CompleteMultipartUploadResult
187
+
188
+ const multipartUploadOptions: OSS.MultipartUploadOptions = {
189
+ progress: (p: number) => {
190
+ onProgress?.(p)
191
+ },
192
+ parallel: 4,
193
+ partSize: MULTIPART_PART_SIZE,
194
+ mime,
195
+ /** 直链打开时优先内联展示,而非附件下载 */
196
+ headers: {
197
+ 'Content-Disposition': 'inline'
198
+ }
199
+ }
200
+ objectResult = await client.multipartUpload(name, content, multipartUploadOptions)
201
+
52
202
  if (objectResult?.res?.status !== 200) {
53
203
  dcgLogger(`OSS 上传失败, ${objectResult?.res?.status}`)
54
204
  }
55
- dcgLogger(`OSS 上传成功, ${objectResult.name || objectResult.url}`)
56
- // const url = `${data.protocol || 'http'}://${data.bucket}.${data.endPoint}/${data.uploadDir}${data.ossFileKey}`
57
- return isPrivate === 1 ? objectResult.name || objectResult.url : objectResult.url
205
+ const requestUrls = objectResult?.res?.requestUrls || []
206
+ const url = requestUrls[0] || ''
207
+ dcgLogger(`OSS 上传成功, ${isPrivate === 1 ? objectResult.name || url : url}`)
208
+ return isPrivate === 1 ? objectResult.name || url : url
58
209
  } catch (error) {
59
210
  dcgLogger(`OSS 上传失败: ${error}`, 'error')
60
211
  }
@@ -82,7 +82,7 @@ const messageToolParameters = {
82
82
  }
83
83
  }
84
84
  },
85
- oneOf: [{ required: ['content'] }, { required: ['media'] }]
85
+ anyOf: [{ required: ['content'] }, { required: ['media'] }]
86
86
  }
87
87
 
88
88
  /** 从正文提取可发送的文件路径(固定挂载 + 当前工作区前缀)。 */