@yiarce/dsh-chat 0.0.0-stage → 0.1.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.
- package/README.md +60 -3
- package/appearance.js +158 -0
- package/chat.html +42 -0
- package/client.js +130 -0
- package/context-menu.js +142 -0
- package/cordis.patch.yml +4 -0
- package/history-client.js +106 -0
- package/history-host.js +86 -0
- package/history-model.js +20 -0
- package/history-store.js +188 -0
- package/host.js +67 -0
- package/login.js +250 -0
- package/media-client.js +46 -0
- package/media.js +197 -0
- package/package.json +52 -6
- package/page.css +1423 -0
- package/page.js +1726 -0
- package/standalone.js +11 -0
- package/window-client.js +31 -0
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
|
+
|
package/media-client.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@yiarce/dsh-chat",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
}
|