@yiarce/dsh-chat 0.0.0-stage → 0.1.1

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/login.js ADDED
@@ -0,0 +1,250 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+
3
+ const CREDENTIAL_KEY_PATTERN = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/
4
+ export function profileCredentialKey(profile = 'default') {
5
+ const profileId = createHash('sha256').update(profile).digest('hex').slice(0, 24)
6
+ return `yiarce-chat/profile-${profileId}`
7
+ }
8
+
9
+ const API = 'https://api.search.yiarce.cn'
10
+ const RESOURCE = 'https://resource.yiarce.cn/'
11
+ export class AccountError extends Error {
12
+ constructor(code, diagnostic) { super(code); this.code = code; if (diagnostic) this.diagnostic = diagnostic }
13
+ }
14
+ const string = value => typeof value === 'string' ? value : ''
15
+ const payloadOf = record => record?.kind === 'grant' && typeof record.payload?.token === 'string' && record.payload.token ? record.payload : null
16
+
17
+ const imageUrl = value => {
18
+ const raw = string(value).trim()
19
+ try { const url = new URL(raw, RESOURCE); return raw && url.protocol === 'https:' ? url.href : '' } catch { return '' }
20
+ }
21
+ export function profileOf(data) {
22
+ if (!data || typeof data !== 'object' || !string(data.username)) throw new AccountError('invalid-response')
23
+ return { username: data.username, nickname: string(data.nickname), avatar: imageUrl(data.avatar), status: string(data.status), city: string(data.city) }
24
+ }
25
+
26
+ /** Standalone chat login. Credentials use a separate profile key; only chatCredential exposes the token. */
27
+ export class AccountService {
28
+ constructor(credentials, { key = 'yiarce-chat/default', fetcher = fetch, pollMs = 2000, leaseMs = 300000, now = Date.now } = {}) {
29
+ if (typeof key !== 'string' || !CREDENTIAL_KEY_PATTERN.test(key)) throw new TypeError('Invalid business account credential key')
30
+ this.credentials = credentials
31
+ this.key = key
32
+ this.fetcher = fetcher
33
+ this.pollMs = pollMs
34
+ this.leaseMs = leaseMs
35
+ this.now = now
36
+ this.life = new AbortController()
37
+ this.listeners = new Set()
38
+ this.epoch = 0
39
+ this.revision = 0
40
+ this.tail = Promise.resolve()
41
+ this.jobs = new Set()
42
+ this.attempt = null
43
+ this.state = { phase: 'restoring', credentialConfigured: false, profile: null, error: null }
44
+ }
45
+ view() {
46
+ const a = this.attempt
47
+ return { ...this.state, revision: this.revision, attempt: a ? { id: a.id, phase: a.phase, qrcode: a.qrcode, error: a.error } : null }
48
+ }
49
+ publish(patch = {}) {
50
+ if (this.life.signal.aborted) return
51
+ Object.assign(this.state, patch)
52
+ this.revision++
53
+ const view = this.view()
54
+ for (const listener of this.listeners) listener(view)
55
+ }
56
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener) }
57
+ serial(operation) {
58
+ const result = this.tail.then(operation)
59
+ this.tail = result.catch(() => {})
60
+ return result
61
+ }
62
+ track(promise) {
63
+ this.jobs.add(promise)
64
+ promise.finally(() => this.jobs.delete(promise)).catch(() => {})
65
+ return promise
66
+ }
67
+ isCurrent(epoch) { return !this.life.signal.aborted && epoch === this.epoch }
68
+ // 聊天服务用的凭据:只把业务 token 交出去,身份由聊天服务自己调 /user/info 查。
69
+ chatCredential() {
70
+ const epoch = this.epoch
71
+ return this.track(this.serial(async () => {
72
+ if (!this.isCurrent(epoch)) throw new AccountError('cancelled')
73
+ if (this.state.phase !== 'signed-in' || !this.state.profile) throw new AccountError('auth-expired')
74
+ let payload
75
+ try { payload = payloadOf(await this.credentials.readRecord(this.key)) } catch { throw new AccountError('storage') }
76
+ if (!this.isCurrent(epoch)) throw new AccountError('cancelled')
77
+ if (!payload) throw new AccountError('auth-expired')
78
+ return { token: payload.token }
79
+ }))
80
+ }
81
+ async request(path, { token, body, signal, query = {} } = {}) {
82
+ const url = new URL(path, API)
83
+ for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value))
84
+ if (token) url.searchParams.set('token', token)
85
+ try {
86
+ const response = await this.fetcher(url, {
87
+ method: body ? 'POST' : 'GET',
88
+ headers: { 'Content-Type': 'text/plain' },
89
+ ...(body ? { body: JSON.stringify(body) } : {}),
90
+ signal: AbortSignal.any([this.life.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])]),
91
+ redirect: 'error',
92
+ })
93
+ if (response.status === 401) throw new AccountError('auth-expired')
94
+ if (!response.ok) throw new AccountError('network')
95
+ const result = await response.json()
96
+ if (!result || !Number.isFinite(result.code)) throw new AccountError('invalid-response')
97
+ if (result.code < 0) {
98
+ // The backend does not document a stable expiry code. Never clear credentials on an arbitrary error.
99
+ const expired = /(?:token|凭据).*(?:失效|过期|无效)|未登录|登录.*(?:失效|过期)|unauthorized/i.test(string(result.msg))
100
+ throw new AccountError(expired ? 'auth-expired' : 'business')
101
+ }
102
+ return result.data
103
+ } catch (error) {
104
+ if (error instanceof AccountError) throw error
105
+ if (this.life.signal.aborted || signal?.aborted) throw new AccountError('cancelled')
106
+ throw new AccountError('network')
107
+ }
108
+ }
109
+ async loadProfile(epoch, token) {
110
+ const profile = profileOf(await this.request('/user/info', { token }))
111
+ if (this.isCurrent(epoch)) this.publish({ phase: 'signed-in', profile, error: null, credentialConfigured: true })
112
+ }
113
+ restore() {
114
+ const epoch = this.epoch
115
+ return this.track(this.serial(async () => {
116
+ if (!this.isCurrent(epoch)) return this.view()
117
+ this.publish({ phase: 'restoring', error: null })
118
+ let payload
119
+ try {
120
+ payload = payloadOf(await this.credentials.readRecord(this.key))
121
+ if (!this.isCurrent(epoch)) return this.view()
122
+ if (!payload) { this.publish({ phase: 'signed-out', profile: null, credentialConfigured: false }); return this.view() }
123
+ this.publish({ credentialConfigured: true })
124
+ if (!payload.refreshedAt || this.now() - payload.refreshedAt >= 86400000) {
125
+ let nextPayload = payload
126
+ await this.credentials.modifyRecord(this.key, async record => {
127
+ const current = payloadOf(record)
128
+ if (!current || !this.isCurrent(epoch)) return undefined
129
+ if (current.refreshedAt && this.now() - current.refreshedAt < 86400000) { nextPayload = current; return undefined }
130
+ const res = await this.request('/user/refreshToken', { token: current.token })
131
+ if (!string(res?.token)) throw new AccountError('invalid-response')
132
+ if (!this.isCurrent(epoch)) return undefined
133
+ nextPayload = { token: res.token, refreshedAt: this.now() }
134
+ return { kind: 'grant', payload: nextPayload }
135
+ })
136
+ payload = nextPayload
137
+ }
138
+ if (this.isCurrent(epoch)) await this.loadProfile(epoch, payload.token)
139
+ } catch (error) {
140
+ if (!this.isCurrent(epoch)) return this.view()
141
+ if (error.code === 'auth-expired') {
142
+ try { await this.credentials.deleteRecord(this.key) } catch { this.publish({ phase: 'unavailable', error: 'storage' }); return this.view() }
143
+ if (this.isCurrent(epoch)) this.publish({ phase: 'signed-out', profile: null, credentialConfigured: false, error: 'auth-expired' })
144
+ } else this.publish({ phase: this.state.profile ? 'signed-in' : 'unavailable', error: error.code || 'storage' })
145
+ }
146
+ return this.view()
147
+ }))
148
+ }
149
+ cancelAttempt(id) {
150
+ const a = this.attempt
151
+ if (!a || (id && a.id !== id)) return this.view()
152
+ a.controller.abort()
153
+ clearTimeout(a.timer)
154
+ this.attempt = null
155
+ this.publish()
156
+ return this.view()
157
+ }
158
+ beginSignIn() {
159
+ this.cancelAttempt()
160
+ const epoch = ++this.epoch
161
+ const a = { id: randomUUID(), epoch, controller: new AbortController(), phase: 'loading', qrcode: '', error: null, scene: '', timer: null }
162
+ this.attempt = a
163
+ a.timer = setTimeout(() => {
164
+ if (this.attempt !== a) return
165
+ a.controller.abort()
166
+ a.phase = 'expired'
167
+ a.qrcode = ''
168
+ this.publish()
169
+ }, this.leaseMs)
170
+ a.timer.unref?.()
171
+ this.publish({ error: null, phase: this.state.profile ? 'signed-in' : 'signed-out' })
172
+ this.track(this.runAttempt(a))
173
+ return this.view()
174
+ }
175
+ async wait(signal) {
176
+ await new Promise((resolve, reject) => {
177
+ if (signal.aborted) { reject(new AccountError('cancelled')); return }
178
+ const onAbort = () => { clearTimeout(timer); reject(new AccountError('cancelled')) }
179
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve() }, this.pollMs)
180
+ signal.addEventListener('abort', onAbort, { once: true })
181
+ })
182
+ }
183
+ async runAttempt(a) {
184
+ const current = () => this.attempt === a && this.isCurrent(a.epoch) && !a.controller.signal.aborted
185
+ try {
186
+ const qr = await this.request('/login/wechatQrcode', { signal: a.controller.signal })
187
+ if (!current()) return
188
+ if (!string(qr?.scene) || !string(qr?.qrcode) || !/^[A-Za-z0-9+/=\r\n]+$/.test(qr.qrcode) || qr.qrcode.length > 1048576) throw new AccountError('invalid-response')
189
+ a.scene = qr.scene
190
+ a.qrcode = 'data:image/png;base64,' + qr.qrcode
191
+ a.phase = 'ready'
192
+ this.publish()
193
+ while (current()) {
194
+ await this.wait(a.controller.signal)
195
+ const result = await this.request('/login/wechat', { body: { scene: a.scene }, signal: a.controller.signal })
196
+ if (!current()) return
197
+ if (result?.status === 'expire') { a.phase = 'expired'; a.qrcode = ''; clearTimeout(a.timer); this.publish(); return }
198
+ if (result?.status === 'success') { a.phase = 'scanned'; this.publish(); continue }
199
+ if (result?.status !== 'ok') continue
200
+ if (!string(result.token)) throw new AccountError('invalid-response')
201
+ await this.serial(async () => {
202
+ if (!current()) return
203
+ let previous
204
+ try {
205
+ previous = await this.credentials.readRecord(this.key)
206
+ if (!current()) return
207
+ await this.credentials.modifyRecord(this.key, async () => current() ? { kind: 'grant', payload: { token: result.token, refreshedAt: this.now() } } : undefined)
208
+ // Closing the UI while the provider commits must not leave a cancelled login behind.
209
+ if (!current()) {
210
+ if (previous) await this.credentials.modifyRecord(this.key, async record => payloadOf(record)?.token === result.token ? previous : undefined)
211
+ else if (payloadOf(await this.credentials.readRecord(this.key))?.token === result.token) await this.credentials.deleteRecord(this.key)
212
+ return
213
+ }
214
+ } catch { throw new AccountError('storage') }
215
+ clearTimeout(a.timer)
216
+ this.attempt = null
217
+ a.controller.abort()
218
+ this.publish({ phase: 'signed-in', profile: null, credentialConfigured: true, error: null })
219
+ try { await this.loadProfile(a.epoch, result.token) }
220
+ catch (error) { if (this.isCurrent(a.epoch)) this.publish({ error: error.code || 'network' }) }
221
+ })
222
+ return
223
+ }
224
+ } catch (error) {
225
+ if (!current()) return
226
+ clearTimeout(a.timer)
227
+ a.phase = 'error'
228
+ a.error = error.code || 'network'
229
+ a.qrcode = ''
230
+ this.publish()
231
+ }
232
+ }
233
+ signOut() {
234
+ ++this.epoch
235
+ this.cancelAttempt()
236
+ return this.track(this.serial(async () => {
237
+ try { await this.credentials.deleteRecord(this.key) }
238
+ catch { throw new AccountError('storage') }
239
+ this.publish({ phase: 'signed-out', profile: null, credentialConfigured: false, error: null })
240
+ return this.view()
241
+ }))
242
+ }
243
+ async dispose() {
244
+ this.life.abort()
245
+ this.cancelAttempt()
246
+ await Promise.allSettled([...this.jobs, this.tail])
247
+ this.listeners.clear()
248
+ }
249
+ }
250
+
@@ -0,0 +1,46 @@
1
+ // One browser adapter for both the plugin panel and standalone plugin page.
2
+ export function createMediaClient({ baseURL, fetcher = window.fetch.bind(window) }) {
3
+ const endpoint = name => new URL(name, baseURL).href
4
+ async function call(name, body, signal, timeout) {
5
+ const response = await fetcher(endpoint(name), { method: "POST", credentials: "same-origin", cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.any([signal, AbortSignal.timeout(timeout)]) })
6
+ const data = await response.json()
7
+ if (!response.ok) throw new Error(data.error || "请求失败")
8
+ return data
9
+ }
10
+ return {
11
+ searchStickers: (keyword, signal, page = 1) => call("stickers", { keyword, page }, signal, 25000),
12
+ loadStickerImage: async (url, signal) => (await call("sticker-image", { url }, signal, 60000)).dataURL,
13
+ async uploadImage(file, signal, onProgress) {
14
+ if (!file.size || file.size > 7 * 1024 * 1024) throw new Error("单张图片不能超过 7MB")
15
+ const uploadSignal = AbortSignal.any([signal, AbortSignal.timeout(120000)])
16
+ const response = await fetcher(endpoint("upload-image"), { method: "POST", credentials: "same-origin", cache: "no-store", headers: { "Content-Type": file.type || "application/octet-stream" }, body: file, signal: uploadSignal })
17
+ if (!response.ok) { const data = await response.json(); throw new Error(data.error || "图片上传失败") }
18
+ if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) throw new Error("上传响应无效")
19
+ const reader = response.body.getReader(), decoder = new TextDecoder()
20
+ let buffer = ""
21
+ try {
22
+ for (;;) {
23
+ uploadSignal.throwIfAborted()
24
+ const { value, done } = await reader.read()
25
+ buffer += done ? decoder.decode() : decoder.decode(value, { stream: true })
26
+ if (buffer.length > 65536) throw new Error("上传进度响应过大")
27
+ let boundary
28
+ while ((boundary = buffer.indexOf(String.fromCharCode(10, 10))) >= 0) {
29
+ const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2)
30
+ const line = block.split(String.fromCharCode(10)).find(line => line.startsWith("data: "))
31
+ if (!line) continue
32
+ const event = JSON.parse(line.slice(6))
33
+ if (event.type === "progress" && Number.isFinite(event.loaded) && Number.isFinite(event.total) && event.total > 0) onProgress?.(event.loaded, event.total)
34
+ if (event.type === "error") throw new Error(event.error || "图片上传失败")
35
+ if (event.type === "complete") {
36
+ const url = new URL(event.url)
37
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.href.length > 2048) throw new Error("图片地址无效")
38
+ return { url: url.href }
39
+ }
40
+ }
41
+ if (done) throw new Error("上传已中断,未收到成功确认")
42
+ }
43
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock() }
44
+ },
45
+ }
46
+ }
package/media.js ADDED
@@ -0,0 +1,197 @@
1
+ import { request as requestHTTP } from 'node:http'
2
+ import { request as requestHTTPS } from 'node:https'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ const IMAGE_LIMIT = 7 * 1024 * 1024
6
+ const RESULT_LIMIT = 2 * 1024 * 1024
7
+ const UPLOAD_SITE = 'https://www.boltp.com/'
8
+ const json = (value, status = 200) => Response.json(value, { status, headers: { 'Cache-Control': 'no-store' } })
9
+ const errorText = error => error?.name === 'TimeoutError' ? '请求超时,请稍后重试' : error?.message || '请求失败'
10
+
11
+ function imageURL(value) {
12
+ if (typeof value !== 'string' || value.length > 2048) throw new Error('图片地址无效')
13
+ const url = new URL(value)
14
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) throw new Error('图片地址无效')
15
+ return url
16
+ }
17
+ function imageType(bytes) {
18
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return ['image/jpeg', 'jpg']
19
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return ['image/png', 'png']
20
+ if (['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString())) return ['image/gif', 'gif']
21
+ if (bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP') return ['image/webp', 'webp']
22
+ throw new Error('仅支持 JPG、PNG、GIF、WebP 图片')
23
+ }
24
+ async function readBody(body, limit, signal) {
25
+ const chunks = []; let size = 0
26
+ for await (const chunk of body || []) {
27
+ signal.throwIfAborted(); size += chunk.byteLength
28
+ if (size > limit) throw new Error(limit === IMAGE_LIMIT ? '单张图片不能超过 7MB' : '响应内容过大')
29
+ chunks.push(Buffer.from(chunk))
30
+ }
31
+ return Buffer.concat(chunks)
32
+ }
33
+ // Bounded Node HTTP requests, including real write progress and upstream abort.
34
+ function request(url, { signal, headers = {}, body = null, limit = RESULT_LIMIT, onWrite = null, method = 'GET' }) {
35
+ signal.throwIfAborted()
36
+ return new Promise((resolve, reject) => {
37
+ let settled = false, response
38
+ const finish = (error, value) => {
39
+ if (settled) return
40
+ settled = true; signal.removeEventListener('abort', abort)
41
+ if (error) { response?.destroy(); req.destroy(); reject(error) } else { if (!req.writableEnded) req.destroy(); resolve(value) }
42
+ }
43
+ const req = (url.protocol === 'https:' ? requestHTTPS : requestHTTP)(url, { method, headers: { 'User-Agent': 'Mozilla/5.0', ...headers }, signal }, incoming => {
44
+ response = incoming
45
+ const chunks = []; let size = 0
46
+ incoming.on('data', chunk => {
47
+ size += chunk.length
48
+ if (size > limit) { finish(new Error('响应内容过大')); return }
49
+ chunks.push(chunk)
50
+ })
51
+ incoming.on('error', error => finish(error))
52
+ incoming.on('aborted', () => finish(new Error('响应已中断')))
53
+ incoming.on('end', () => finish(null, { status: incoming.statusCode, headers: incoming.headers, body: Buffer.concat(chunks) }))
54
+ })
55
+ const abort = () => finish(signal.reason || new Error('请求已取消'))
56
+ signal.addEventListener('abort', abort, { once: true })
57
+ req.on('error', error => finish(error))
58
+ if (signal.aborted) { abort(); return }
59
+ void (async () => {
60
+ if (body) {
61
+ let written = 0
62
+ for (let offset = 0; offset < body.length && !settled; offset += 64 * 1024) {
63
+ signal.throwIfAborted()
64
+ const chunk = body.subarray(offset, offset + 64 * 1024)
65
+ await new Promise((done, fail) => req.write(chunk, error => error ? fail(error) : done()))
66
+ written += chunk.length
67
+ if (!settled) onWrite?.(written)
68
+ }
69
+ }
70
+ if (!settled) req.end()
71
+ })().catch(error => finish(error))
72
+ })
73
+ }
74
+ function parseJSON(result) {
75
+ try { return JSON.parse(result.body.toString('utf8')) } catch { throw new Error('上游返回了无法解析的结果') }
76
+ }
77
+ function imageReferer(url) {
78
+ if (url.hostname === 'free.boltp.com') return UPLOAD_SITE
79
+ if (url.hostname === 'img.adoutu.com') return 'https://www.adoutu.com/'
80
+ if (url.hostname === 'ps.ssl.qhimg.com') return 'https://tuchuang.zf123.cn/'
81
+ return url.origin + '/'
82
+ }
83
+ async function search(input, signal) {
84
+ const keyword = typeof input.keyword === 'string' ? input.keyword.trim() : ''
85
+ if (Array.from(keyword).length < 2 || Array.from(keyword).length > 4) throw new Error('请输入 2–4 个字符')
86
+ const page = input.page ?? 1
87
+ if (!Number.isInteger(page) || page < 1 || page > 10000) throw new Error('表情页码无效')
88
+ const url = new URL('https://h5api.sginput.qq.com/wxbq/search')
89
+ url.search = new URLSearchParams({ key: keyword, page: String(page), num: '40' }).toString()
90
+ const result = await request(url, { signal, headers: { Accept: 'application/json' } })
91
+ if (result.status !== 200) throw new Error('表情搜索返回 HTTP ' + result.status)
92
+ const data = parseJSON(result)
93
+ if (data.code !== 0 || !Array.isArray(data.data)) throw new Error(data.msg || '表情搜索结果无效')
94
+ const seen = new Set(), images = []
95
+ for (const item of data.data) {
96
+ try {
97
+ const url = imageURL(item.indexUrl).href
98
+ if (!seen.has(url)) { seen.add(url); images.push({ url, alt: '' }) }
99
+ } catch { /* Ignore malformed result URLs. */ }
100
+ }
101
+ return { images, hasMore: Boolean(data.hasMore) && !data.endFlag && images.length > 0 }
102
+ }
103
+ async function loadImage(input, signal) {
104
+ let url = imageURL(input.url)
105
+ for (let redirects = 0; redirects <= 3; redirects++) {
106
+ const result = await request(url, { signal, limit: IMAGE_LIMIT, headers: { Referer: imageReferer(url), Accept: 'image/*' } })
107
+ if ([301, 302, 303, 307, 308].includes(result.status) && result.headers.location) {
108
+ url = imageURL(new URL(result.headers.location, url).href); continue
109
+ }
110
+ if (result.status !== 200) throw new Error('图片站点返回 HTTP ' + result.status)
111
+ const [mime] = imageType(result.body)
112
+ return { dataURL: 'data:' + mime + ';base64,' + result.body.toString('base64') }
113
+ }
114
+ throw new Error('图片重定向过多')
115
+ }
116
+
117
+ async function uploadImage(bytes, signal, push) {
118
+ const [mime, extension] = imageType(bytes)
119
+ const initialized = await request(new URL(UPLOAD_SITE + "api/v2/configs"), { signal, headers: { Accept: "application/json", Referer: UPLOAD_SITE } })
120
+ if (initialized.status !== 200 || parseJSON(initialized).status !== "success") throw new Error("无法建立图床会话")
121
+ const cookies = new Map()
122
+ for (const header of initialized.headers["set-cookie"] || []) {
123
+ const pair = header.split(";", 1)[0], index = pair.indexOf("=")
124
+ if (index > 0) cookies.set(pair.slice(0, index), pair.slice(index + 1))
125
+ }
126
+ const token = cookies.get("XSRF-TOKEN")
127
+ if (!token) throw new Error("图床未返回上传令牌")
128
+ const boundary = "----yiarce-chat-" + randomUUID()
129
+ const quote = String.fromCharCode(34), crlf = String.fromCharCode(13, 10)
130
+ const prefix = Buffer.from(["--" + boundary, "Content-Disposition: form-data; name=" + quote + "file" + quote + "; filename=" + quote + "chat-image." + extension + quote, "Content-Type: " + mime, "", ""].join(crlf))
131
+ const suffix = Buffer.from(["", "--" + boundary, "Content-Disposition: form-data; name=" + quote + "storage_id" + quote, "", "4", "--" + boundary, "Content-Disposition: form-data; name=" + quote + "is_public" + quote, "", "0", "--" + boundary + "--", ""].join(crlf))
132
+ const body = Buffer.concat([prefix, bytes, suffix])
133
+ let last = 0
134
+ push({ type: "progress", loaded: 0, total: bytes.length })
135
+ const result = await request(new URL(UPLOAD_SITE + "api/v2/upload"), {
136
+ method: "POST", signal, body, headers: {
137
+ "Content-Type": "multipart/form-data; boundary=" + boundary, "Content-Length": String(body.length),
138
+ Accept: "application/json", Referer: UPLOAD_SITE, Origin: new URL(UPLOAD_SITE).origin,
139
+ Cookie: [...cookies].map(([key, value]) => key + "=" + value).join("; "), "X-XSRF-TOKEN": decodeURIComponent(token),
140
+ },
141
+ onWrite(written) {
142
+ const loaded = Math.max(0, Math.min(bytes.length, written - prefix.length))
143
+ if (loaded === bytes.length || Date.now() - last >= 100) { last = Date.now(); push({ type: "progress", loaded, total: bytes.length }) }
144
+ },
145
+ })
146
+ const reply = parseJSON(result)
147
+ if (result.status !== 200 || reply.status !== "success") throw new Error(String(reply.message || "图床上传失败").slice(0, 200))
148
+ return imageURL(reply.data?.public_url).href
149
+ }
150
+
151
+ export function registerMedia(ctx) {
152
+ const life = new AbortController()
153
+ ctx.effect(() => () => life.abort())
154
+ for (const [path, timeout, handler] of [["stickers", 20000, search], ["sticker-image", 45000, loadImage]]) {
155
+ ctx.effect(() => ctx.connection.fetch.register({
156
+ path: "/api/yiarce-chat/" + path, methods: ["POST"], requestBody: "streaming",
157
+ async fetch(incoming) {
158
+ try {
159
+ const signal = AbortSignal.any([life.signal, incoming.signal, AbortSignal.timeout(timeout)])
160
+ const body = await readBody(incoming.body, 4096, signal)
161
+ let input
162
+ try { input = JSON.parse(body.toString()) } catch { return json({ error: "请求格式无效" }, 400) }
163
+ if (!input || typeof input !== "object" || Array.isArray(input)) return json({ error: "请求格式无效" }, 400)
164
+ return json(await handler(input, signal))
165
+ } catch (error) { return json({ error: errorText(error) }, 502) }
166
+ },
167
+ }))
168
+ }
169
+ ctx.effect(() => ctx.connection.fetch.register({
170
+ path: "/api/yiarce-chat/upload-image", methods: ["POST"], requestBody: "streaming",
171
+ async fetch(incoming) {
172
+ const controller = new AbortController()
173
+ const signal = AbortSignal.any([life.signal, incoming.signal, controller.signal, AbortSignal.timeout(90000)])
174
+ let bytes
175
+ try {
176
+ const length = Number(incoming.headers.get("content-length") || 0)
177
+ if (length > IMAGE_LIMIT) return json({ error: "单张图片不能超过 7MB" }, 413)
178
+ bytes = await readBody(incoming.body, IMAGE_LIMIT, signal)
179
+ if (!bytes.length) throw new Error("图片内容为空")
180
+ imageType(bytes)
181
+ } catch (error) { return json({ error: errorText(error) }, 400) }
182
+ const encoder = new TextEncoder()
183
+ const stream = new ReadableStream({
184
+ start(output) {
185
+ let closed = false
186
+ const close = () => { if (closed) return; closed = true; signal.removeEventListener("abort", close); try { output.close() } catch {} }
187
+ const push = value => { if (!closed) try { output.enqueue(encoder.encode("data: " + JSON.stringify(value) + String.fromCharCode(10, 10))) } catch { controller.abort() } }
188
+ signal.addEventListener("abort", close, { once: true })
189
+ if (signal.aborted) { close(); return }
190
+ void uploadImage(bytes, signal, push).then(url => push({ type: "complete", url }), error => push({ type: "error", error: errorText(error) })).finally(close)
191
+ },
192
+ cancel() { controller.abort() },
193
+ })
194
+ return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "X-Accel-Buffering": "no" } })
195
+ },
196
+ }))
197
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
- {
2
- "name": "@yiarce/dsh-chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@yiarce/dsh-chat",
3
+ "version": "0.1.1",
4
+ "description": "独立聊天插件:共享聊天页面、图片上传、表情搜索;可选桌面独立窗口。",
5
+ "type": "module",
6
+ "main": "./host.js",
7
+ "exports": {
8
+ ".": "./host.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "host.js",
14
+ "login.js",
15
+ "media.js",
16
+ "client.js",
17
+ "chat.html",
18
+ "page.js",
19
+ "page.css",
20
+ "context-menu.js",
21
+ "media-client.js",
22
+ "window-client.js",
23
+ "standalone.js",
24
+ "appearance.js",
25
+ "cordis.patch.yml",
26
+ "README.md",
27
+ "history-model.js",
28
+ "history-store.js",
29
+ "history-host.js",
30
+ "history-client.js"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "immediately": true
39
+ }
40
+ },
41
+ "keywords": [
42
+ "dsh",
43
+ "deepseek-harness",
44
+ "dsh-plugin",
45
+ "chat",
46
+ "websocket"
47
+ ],
48
+ "license": "MIT",
49
+ "engines": {
50
+ "node": ">=22.13.0"
51
+ }
52
+ }