@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.
@@ -0,0 +1,109 @@
1
+ import { durableEntry, sessionInfo } from './history-model.js'
2
+
3
+ export class ChatHistoryClient {
4
+ constructor({ baseURL, onEvent, onError }) {
5
+ this.endpoint = new URL('./history', baseURL).href
6
+ this.onEvent = onEvent; this.onError = onError
7
+ this.binding = null; this.tail = Promise.resolve(); this.drafts = new Map()
8
+ }
9
+ get account() { return this.binding?.account }
10
+ get client() { return this.binding?.client }
11
+ get ready() { return Boolean(this.binding?.ready) }
12
+ receive(binding, event) {
13
+ if (this.binding !== binding || event.account !== binding.account) return
14
+ if (event.type === 'snapshot') {
15
+ // A plugin restart can reset an EventSource connection; snapshots repair gaps.
16
+ binding.revision = event.revision; binding.ready = true; binding.reportedError = false
17
+ this.onEvent(event)
18
+ binding.resolve?.(); binding.resolve = null
19
+ } else if (!binding.seen.has(event.revision)) {
20
+ binding.seen.add(event.revision)
21
+ if (binding.seen.size > 4096) binding.seen.delete(binding.seen.values().next().value)
22
+ binding.revision = Math.max(binding.revision, event.revision); this.onEvent(event)
23
+ }
24
+ }
25
+ async open(userId, server) {
26
+ const account = JSON.stringify([server, String(userId)])
27
+ if (this.binding?.account === account) return this.binding.ready ? undefined : this.binding.initialized
28
+ this.close()
29
+ const binding = { account, client: crypto.randomUUID(), revision: -1, ready: false, seen: new Set() }
30
+ this.binding = binding
31
+ binding.initialized = new Promise((resolve, reject) => { binding.resolve = resolve; binding.reject = reject })
32
+ binding.initialized.catch(() => {})
33
+ const url = new URL(this.endpoint + '/events')
34
+ url.searchParams.set('account', account); url.searchParams.set('client', binding.client)
35
+ const stream = binding.stream = new EventSource(url, { withCredentials: true })
36
+ const timeout = setTimeout(() => {
37
+ if (!binding.ready) { binding.reject?.(new Error('无法读取聊天记录,请检查插件存储服务')); binding.reject = null }
38
+ }, 10000)
39
+ binding.timer = timeout
40
+ stream.onmessage = event => {
41
+ try { this.receive(binding, JSON.parse(event.data)); if (binding.ready) clearTimeout(timeout) }
42
+ catch (error) { this.onError(error) }
43
+ }
44
+ stream.onerror = () => {
45
+ if (this.binding === binding && !binding.reportedError) { binding.reportedError = true; this.onError(new Error('聊天记录同步连接中断,正在重新连接')) }
46
+ }
47
+ return binding.initialized
48
+ }
49
+ request(action, fields = {}, binding = this.binding, keepalive = false) {
50
+ if (!binding) return Promise.reject(new Error('聊天记录尚未绑定账号'))
51
+ // Capture values now; queued writes must never inherit another account/draft.
52
+ const body = JSON.stringify({ action, account: binding.account, client: binding.client, ...fields })
53
+ const operation = async () => {
54
+ const response = await fetch(this.endpoint, { method: 'POST', credentials: 'same-origin', cache: 'no-store',
55
+ headers: { 'Content-Type': 'application/json' }, body, keepalive, signal: AbortSignal.timeout(15000) })
56
+ const data = await response.json()
57
+ if (!response.ok) throw new Error(data.error || '聊天记录保存失败')
58
+ if (data.event) this.receive(binding, data.event)
59
+ return data
60
+ }
61
+ const result = this.tail.then(operation)
62
+ this.tail = result.catch(() => {})
63
+ return result
64
+ }
65
+ put(item, entry, options = {}) {
66
+ return this.request('put', { key: item.key, session: sessionInfo(item), entry: durableEntry(entry), ...options })
67
+ }
68
+ session(item) { return this.request('session', { key: item.key, session: sessionInfo(item) }) }
69
+ recent(key) { return this.request('recent', { key }) }
70
+ async *sync(key, ids) {
71
+ const batches = []; let batch = [], length = 0
72
+ for (const id of ids) {
73
+ if (batch.length && (batch.length >= 100 || length + id.length > 12000)) { batches.push(batch); batch = []; length = 0 }
74
+ batch.push(id); length += id.length
75
+ }
76
+ if (batch.length || !batches.length) batches.push(batch)
77
+ for (let index = 0; index < batches.length; index++) yield await this.request('sync', { key, ids: batches[index], includeRecent: index === 0 })
78
+ }
79
+ read(key) { return this.request('read', { key }) }
80
+ change(action, key, id) { return this.request(action, { key, id }) }
81
+ contacts() { return this.request('contacts') }
82
+ contact(userId, relation, profile = {}) { return this.request('contact', { userId, relation, nickname: profile.nickname, avatar: profile.avatar }) }
83
+ requestFriend(fields) { return this.request('request', fields) }
84
+ dirty(key) { return this.drafts.get(key)?.binding === this.binding }
85
+ draft(key, text) {
86
+ if (!this.binding) return
87
+ const previous = this.drafts.get(key); clearTimeout(previous?.timer)
88
+ const value = { text, binding: this.binding }
89
+ value.timer = setTimeout(() => { void this.flushDraft(key).catch(this.onError) }, 250)
90
+ this.drafts.set(key, value)
91
+ }
92
+ cancelDraft(key) { const value = this.drafts.get(key); clearTimeout(value?.timer); this.drafts.delete(key) }
93
+ async flushDraft(key, keepalive = false) {
94
+ const value = this.drafts.get(key)
95
+ if (!value) return
96
+ clearTimeout(value.timer)
97
+ await this.request('draft', { key, draft: value.text }, value.binding, keepalive)
98
+ if (this.drafts.get(key) === value) this.drafts.delete(key)
99
+ }
100
+ close() {
101
+ const binding = this.binding
102
+ if (!binding) return
103
+ for (const key of this.drafts.keys()) void this.flushDraft(key, true).catch(this.onError)
104
+ binding.stream.close(); clearTimeout(binding.timer)
105
+ binding.reject?.(new Error('聊天记录账号已切换')); binding.reject = null
106
+ void this.request('end-client', {}, binding, true).catch(this.onError)
107
+ this.binding = null
108
+ }
109
+ }
@@ -0,0 +1,86 @@
1
+ import { ChatHistoryStore } from './history-store.js'
2
+
3
+ export function registerHistory(ctx) {
4
+ let store, storageError, disposed = false
5
+ const life = new AbortController(), disconnects = new Map(), streams = new Map()
6
+ try { store = new ChatHistoryStore() } catch (error) { storageError = error }
7
+ const json = (value, status = 200) => Response.json(value, { status, headers: { 'Cache-Control': 'no-store' } })
8
+ const requireStore = () => { if (!store || disposed) throw new Error('聊天记录数据库不可用:' + (storageError?.message || '服务已关闭')); return store }
9
+ const identity = input => {
10
+ if (typeof input.account !== 'string' || input.account.length > 1200 || typeof input.client !== 'string' || !/^[\w-]{1,100}$/.test(input.client)) throw new Error('存储请求标识无效')
11
+ // list() validates the account structure without trusting it as a path.
12
+ const account = JSON.parse(input.account)
13
+ if (!Array.isArray(account) || account.length !== 2 || account.some(value => typeof value !== 'string' || !value || value.length > 512)) throw new Error('账号标识无效')
14
+ return input
15
+ }
16
+ ctx.effect(() => () => {
17
+ disposed = true; life.abort()
18
+ for (const timer of disconnects.values()) clearTimeout(timer)
19
+ disconnects.clear(); store?.close()
20
+ })
21
+ ctx.effect(() => ctx.connection.fetch.register({
22
+ path: '/api/yiarce-chat/history', methods: ['POST'], requestBody: 'streaming',
23
+ async fetch(request) {
24
+ try {
25
+ let bytes = 0; const chunks = []
26
+ for await (const chunk of request.body || []) {
27
+ bytes += chunk.byteLength
28
+ if (bytes > 64 * 1024) return json({ error: '存储请求过大' }, 413)
29
+ chunks.push(Buffer.from(chunk))
30
+ }
31
+ const input = identity(JSON.parse(Buffer.concat(chunks).toString())), db = requireStore()
32
+ const { account, client } = input
33
+ if (input.action === 'list') return json({ sessions: db.list(account), revision: db.revision() })
34
+ if (input.action === 'sync') return json(db.sync(account, input.key, input.ids, input.includeRecent))
35
+ if (input.action === 'recent') return json({ entries: db.recent(account, input.key), revision: db.revision() })
36
+ if (input.action === 'put') return json(db.put(account, client, input))
37
+ if (input.action === 'end-client') { db.endClient(account, client); return json({ ok: true }) }
38
+ return json(db.change(account, client, input))
39
+ } catch (error) { return json({ error: '聊天记录保存或读取失败:' + error.message }, 503) }
40
+ },
41
+ }))
42
+ ctx.effect(() => ctx.connection.fetch.register({
43
+ path: '/api/yiarce-chat/history/events', methods: ['GET'], requestBody: 'buffered',
44
+ async fetch(request) {
45
+ try {
46
+ const url = new URL(request.url)
47
+ const { account, client } = identity({ account: url.searchParams.get('account'), client: url.searchParams.get('client') })
48
+ const db = requireStore(), key = JSON.stringify([account, client])
49
+ clearTimeout(disconnects.get(key)); disconnects.delete(key)
50
+ streams.set(key, (streams.get(key) || 0) + 1)
51
+ const encoder = new TextEncoder()
52
+ let close = () => {}
53
+ const body = new ReadableStream({
54
+ start(output) {
55
+ let closed = false, unsubscribe = () => {}, heartbeat
56
+ close = () => {
57
+ if (closed) return
58
+ closed = true; unsubscribe(); clearInterval(heartbeat)
59
+ request.signal.removeEventListener('abort', close); life.signal.removeEventListener('abort', close)
60
+ try { output.close() } catch {}
61
+ const count = (streams.get(key) || 1) - 1
62
+ if (count) streams.set(key, count); else streams.delete(key)
63
+ if (!disposed && !count) {
64
+ const timer = setTimeout(() => {
65
+ disconnects.delete(key)
66
+ if (!disposed && !streams.has(key)) try { db.endClient(account, client) } catch {}
67
+ }, 3000)
68
+ timer.unref?.(); disconnects.set(key, timer)
69
+ }
70
+ }
71
+ const push = value => { if (!closed) try { output.enqueue(encoder.encode('data: ' + JSON.stringify(value) + '\n\n')) } catch { close() } }
72
+ request.signal.addEventListener('abort', close, { once: true }); life.signal.addEventListener('abort', close, { once: true })
73
+ if (request.signal.aborted || life.signal.aborted) { close(); return }
74
+ const sessions = db.list(account), contacts = db.contacts(account), requests = db.requests(account)
75
+ unsubscribe = db.subscribe(event => { if (event.account === account) push(event) })
76
+ push({ type: 'snapshot', account, sessions, contacts, requests, revision: db.revision() })
77
+ heartbeat = setInterval(() => { if (!closed) try { output.enqueue(encoder.encode(': heartbeat\n\n')) } catch { close() } }, 15000)
78
+ heartbeat.unref?.()
79
+ },
80
+ cancel() { close() },
81
+ })
82
+ return new Response(body, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' } })
83
+ } catch (error) { return json({ error: '聊天记录读取失败:' + error.message }, 503) }
84
+ },
85
+ }))
86
+ }
@@ -0,0 +1,20 @@
1
+ // Stable identifiers are shared by the browser and the SQLite service.
2
+ export function entryId(message) {
3
+ if (message.clientMessageId) return 'client:' + message.fromUserId + ':' + message.clientMessageId
4
+ let image = message.sticker?.url || ''
5
+ try { if (image) image = new URL(image).href } catch {}
6
+ return 'server:' + JSON.stringify([message.seq || 0, message.fromUserId, message.toUserId || '', message.roomId || 0, message.sentAtMs || 0, message.content || '', image])
7
+ }
8
+ export function durableEntry(entry) {
9
+ const source = entry.message
10
+ const message = { ...source }
11
+ const url = source.sticker?.url
12
+ if (url && /^https?:\/\//.test(url)) message.sticker = { url }
13
+ else delete message.sticker
14
+ return { id: entry.id || entryId(source), message,
15
+ sendStatus: entry.sendStatus || 'sent', sendError: entry.sendError || '',
16
+ imagePending: Boolean(entry.imagePending || (entry.localImage && !message.sticker)) }
17
+ }
18
+ export function sessionInfo(item) {
19
+ return { key: item.key, kind: item.kind, id: String(item.id), name: item.name, avatar: item.avatar || '' }
20
+ }
@@ -0,0 +1,238 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { createHash } from 'node:crypto'
6
+ import { entryId } from './history-model.js'
7
+
8
+ function storagePath() {
9
+ let home = (process.env.DSH_HOME || '').trim() || join(homedir(), '.dsh')
10
+ if (home === '~') home = homedir()
11
+ else if (/^~[\\/]/.test(home)) home = join(homedir(), home.slice(2))
12
+ const profile = process.env.DSH_PROFILE_DIR || process.env.DSH_PROFILE || 'default'
13
+ const folder = join(home, 'yiarce-chat', createHash('sha256').update(profile).digest('hex').slice(0, 24))
14
+ mkdirSync(folder, { recursive: true })
15
+ return join(folder, 'history.sqlite')
16
+ }
17
+ const text = (value, max = 256) => typeof value === 'string' ? value.slice(0, max) : ''
18
+ function roomKey(key) {
19
+ if (typeof key !== 'string' || key.length > 512 || !/^(room|private):[^\r\n]+$/.test(key)) throw new Error('会话标识无效')
20
+ return key
21
+ }
22
+ function accountId(account) {
23
+ let identity
24
+ try { identity = JSON.parse(account) } catch { throw new Error('账号标识无效') }
25
+ if (!Array.isArray(identity) || identity.length !== 2 || identity.some(value => typeof value !== 'string' || !value || value.length > 512)) throw new Error('账号标识无效')
26
+ return identity[1]
27
+ }
28
+ function cleanEntry(input, key, user) {
29
+ const source = input?.message
30
+ if (!source || typeof source !== 'object' || !text(source.fromUserId)) throw new Error('消息格式无效')
31
+ const message = {
32
+ fromUserId: text(source.fromUserId), fromNickname: text(source.fromNickname), fromAvatar: text(source.fromAvatar, 2048),
33
+ toUserId: text(source.toUserId), roomId: Number(source.roomId) || 0,
34
+ privateMsg: key.startsWith('private:'), content: text(source.content, 2000),
35
+ sentAtMs: Number(source.sentAtMs) || Date.now(), seq: Number(source.seq) || 0,
36
+ clientMessageId: text(source.clientMessageId),
37
+ }
38
+ if (source.sticker?.url) {
39
+ const url = new URL(source.sticker.url)
40
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.href.length > 2048) throw new Error('图片地址无效')
41
+ message.sticker = { url: url.href }
42
+ }
43
+ const id = entryId(message)
44
+ if (input.id && input.id !== id) throw new Error('消息标识不一致')
45
+ const status = message.fromUserId !== user ? 'sent' : ['uploading', 'sending', 'sent', 'failed'].includes(input.sendStatus) ? input.sendStatus : 'sent'
46
+ return { id, message, sendStatus: status, sendError: text(input.sendError, 500), imagePending: Boolean(input.imagePending && !message.sticker) }
47
+ }
48
+ export class ChatHistoryStore {
49
+ constructor() {
50
+ this.db = new DatabaseSync(storagePath())
51
+ this.listeners = new Set()
52
+ this.db.exec(`
53
+ PRAGMA journal_mode=WAL;
54
+ PRAGMA foreign_keys=ON;
55
+ PRAGMA busy_timeout=5000;
56
+ CREATE TABLE IF NOT EXISTS history_meta (name TEXT PRIMARY KEY, value INTEGER NOT NULL);
57
+ INSERT OR IGNORE INTO history_meta VALUES ('revision', 0);
58
+ CREATE TABLE IF NOT EXISTS sessions (
59
+ account TEXT NOT NULL, session_key TEXT NOT NULL, kind TEXT NOT NULL, peer_id TEXT NOT NULL,
60
+ name TEXT NOT NULL, avatar TEXT NOT NULL DEFAULT '', preview TEXT NOT NULL DEFAULT '',
61
+ updated INTEGER NOT NULL DEFAULT 0, unread INTEGER NOT NULL DEFAULT 0, draft TEXT NOT NULL DEFAULT '',
62
+ PRIMARY KEY (account, session_key)
63
+ );
64
+ CREATE TABLE IF NOT EXISTS messages (
65
+ ordinal INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL, session_key TEXT NOT NULL,
66
+ message_id TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL, error TEXT NOT NULL DEFAULT '',
67
+ image_pending INTEGER NOT NULL DEFAULT 0, owner TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL,
68
+ UNIQUE(account, session_key, message_id)
69
+ );
70
+ CREATE INDEX IF NOT EXISTS messages_recent ON messages(account, session_key, ordinal DESC);
71
+ CREATE TABLE IF NOT EXISTS message_tombstones (
72
+ account TEXT NOT NULL, session_key TEXT NOT NULL, message_id TEXT NOT NULL,
73
+ PRIMARY KEY(account, session_key, message_id)
74
+ );
75
+ CREATE TABLE IF NOT EXISTS contacts (
76
+ account TEXT NOT NULL, user_id TEXT NOT NULL, nickname TEXT NOT NULL DEFAULT '', avatar TEXT NOT NULL DEFAULT '',
77
+ relation TEXT NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL,
78
+ PRIMARY KEY(account, user_id)
79
+ );
80
+ CREATE TABLE IF NOT EXISTS friend_requests (
81
+ account TEXT NOT NULL, request_id TEXT NOT NULL, peer_id TEXT NOT NULL, direction TEXT NOT NULL,
82
+ status TEXT NOT NULL, nickname TEXT NOT NULL DEFAULT '', avatar TEXT NOT NULL DEFAULT '',
83
+ created INTEGER NOT NULL, updated INTEGER NOT NULL,
84
+ PRIMARY KEY(account, request_id)
85
+ );
86
+ CREATE INDEX IF NOT EXISTS friend_requests_peer ON friend_requests(account, peer_id, updated DESC);
87
+ UPDATE messages SET error=CASE WHEN status='uploading' THEN '图片上传未完成' ELSE '发送未完成,未自动重发' END,
88
+ status='failed' WHERE status IN ('uploading','sending');
89
+ `)
90
+ }
91
+ revision() { return this.db.prepare("SELECT value FROM history_meta WHERE name='revision'").get().value }
92
+ nextRevision() { this.db.prepare("UPDATE history_meta SET value=value+1 WHERE name='revision'").run(); return this.revision() }
93
+ transaction(work) {
94
+ this.db.exec('BEGIN IMMEDIATE')
95
+ try { const result = work(); this.db.exec('COMMIT'); return result } catch (error) { this.db.exec('ROLLBACK'); throw error }
96
+ }
97
+ emit(event, client) {
98
+ event.client = client
99
+ for (const listener of this.listeners) { try { listener(event) } catch {} }
100
+ return event
101
+ }
102
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener) }
103
+ session(account, key) {
104
+ const row = this.db.prepare(`SELECT s.*, (SELECT COUNT(*) FROM messages m WHERE m.account=s.account AND m.session_key=s.session_key) AS message_count
105
+ FROM sessions s WHERE account=? AND session_key=?`).get(account, key)
106
+ return row ? { key: row.session_key, kind: row.kind, id: row.peer_id, name: row.name, avatar: row.avatar,
107
+ preview: row.preview, updated: row.updated, unread: row.unread, draft: row.draft, messageCount: row.message_count } : null
108
+ }
109
+ ensureSession(account, info) {
110
+ const key = roomKey(info.key), kind = key.startsWith('room:') ? 'room' : 'private', id = key.slice(key.indexOf(':') + 1)
111
+ const oldName = this.db.prepare('SELECT name FROM sessions WHERE account=? AND session_key=?').get(account, key)?.name
112
+ this.db.prepare(`INSERT INTO sessions(account,session_key,kind,peer_id,name,avatar) VALUES (?,?,?,?,?,?)
113
+ ON CONFLICT(account,session_key) DO UPDATE SET name=CASE WHEN excluded.name<>'' THEN excluded.name ELSE sessions.name END,
114
+ avatar=CASE WHEN excluded.avatar<>'' THEN excluded.avatar ELSE sessions.avatar END`).run(account, key, kind, id,
115
+ key === 'room:1' ? '广场' : text(info.name) || oldName || (kind === 'room' ? id + ' 号房' : id), text(info.avatar, 2048))
116
+ }
117
+ list(account) {
118
+ accountId(account)
119
+ this.ensureSession(account, { key: 'room:1', name: '广场' })
120
+ return this.db.prepare('SELECT session_key FROM sessions WHERE account=? ORDER BY updated DESC').all(account).map(row => this.session(account, row.session_key))
121
+ }
122
+ entry(row) {
123
+ return { id: row.message_id, order: row.ordinal, message: JSON.parse(row.payload), sendStatus: row.status, sendError: row.error, imagePending: Boolean(row.image_pending), revision: row.revision }
124
+ }
125
+ contacts(account) {
126
+ accountId(account)
127
+ return this.db.prepare('SELECT user_id AS userId,nickname,avatar,relation,created,updated FROM contacts WHERE account=? ORDER BY updated DESC').all(account)
128
+ }
129
+ requests(account) {
130
+ accountId(account)
131
+ return this.db.prepare('SELECT request_id AS requestId,peer_id AS peerId,direction,status,nickname,avatar,created,updated FROM friend_requests WHERE account=? ORDER BY updated DESC').all(account)
132
+ }
133
+ sync(account, key, ids, includeRecent) {
134
+ accountId(account); roomKey(key)
135
+ if (!Array.isArray(ids) || ids.length > 100 || ids.some(id => typeof id !== 'string' || id.length > 8192)) throw new Error('同步消息标识无效')
136
+ const query = this.db.prepare('SELECT * FROM messages WHERE account=? AND session_key=? AND message_id=?')
137
+ return { requested: ids, entries: ids.map(id => query.get(account, key, id)).filter(Boolean).map(row => this.entry(row)),
138
+ recent: includeRecent ? this.recent(account, key) : [], revision: this.revision() }
139
+ }
140
+ recent(account, key) {
141
+ accountId(account); roomKey(key)
142
+ return this.db.prepare('SELECT * FROM messages WHERE account=? AND session_key=? ORDER BY ordinal DESC LIMIT 50').all(account, key).reverse().map(row => this.entry(row))
143
+ }
144
+ refreshPreview(account, key) {
145
+ const row = this.db.prepare('SELECT * FROM messages WHERE account=? AND session_key=? ORDER BY ordinal DESC LIMIT 1').get(account, key)
146
+ if (!row) { this.db.prepare("UPDATE sessions SET preview='',updated=0 WHERE account=? AND session_key=?").run(account, key); return }
147
+ const m = JSON.parse(row.payload), mine = m.fromUserId === accountId(account)
148
+ const preview = (mine ? '我: ' : key.startsWith('room:') ? (m.fromNickname || m.fromUserId) + ': ' : '') +
149
+ (m.sticker || row.image_pending ? '[图片]' + (m.content ? ' ' + m.content : '') : m.content || '')
150
+ this.db.prepare('UPDATE sessions SET preview=?,updated=? WHERE account=? AND session_key=?').run(preview, m.sentAtMs, account, key)
151
+ }
152
+ put(account, client, input) {
153
+ const user = accountId(account), key = roomKey(input.key), entry = cleanEntry(input.entry, key, user)
154
+ if (this.db.prepare('SELECT 1 FROM message_tombstones WHERE account=? AND session_key=? AND message_id=?').get(account, key, entry.id)) return { ignored: true }
155
+ const previous = this.db.prepare('SELECT * FROM messages WHERE account=? AND session_key=? AND message_id=?').get(account, key, entry.id)
156
+ if (previous && (previous.status === 'sent' && entry.sendStatus !== 'sent' || previous.payload === JSON.stringify(entry.message) && previous.status === entry.sendStatus && previous.error === entry.sendError && Boolean(previous.image_pending) === entry.imagePending)) return { entry: this.entry(previous), session: this.session(account, key), revision: this.revision() }
157
+ const event = this.transaction(() => {
158
+ this.ensureSession(account, { ...input.session, key })
159
+ const revision = this.nextRevision()
160
+ this.db.prepare(`INSERT INTO messages(account,session_key,message_id,payload,status,error,image_pending,owner,revision) VALUES (?,?,?,?,?,?,?,?,?)
161
+ ON CONFLICT(account,session_key,message_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,error=excluded.error,image_pending=excluded.image_pending,revision=excluded.revision`).run(
162
+ account, key, entry.id, JSON.stringify(entry.message), entry.sendStatus, entry.sendError, Number(entry.imagePending), client, revision)
163
+ if (!previous && !input.read && entry.message.fromUserId !== user) this.db.prepare('UPDATE sessions SET unread=unread+1 WHERE account=? AND session_key=?').run(account, key)
164
+ if (input.read) this.db.prepare('UPDATE sessions SET unread=0 WHERE account=? AND session_key=?').run(account, key)
165
+ if (input.clearDraft) this.db.prepare("UPDATE sessions SET draft='' WHERE account=? AND session_key=?").run(account, key)
166
+ this.refreshPreview(account, key)
167
+ return { type: 'message', account, key, entry: this.entry(this.db.prepare('SELECT * FROM messages WHERE account=? AND session_key=? AND message_id=?').get(account, key, entry.id)), session: this.session(account, key), revision }
168
+ })
169
+ return { event: this.emit(event, client), entry: event.entry, session: event.session, revision: event.revision }
170
+ }
171
+ change(account, client, input) {
172
+ if (input.action === 'contacts') return { contacts: this.contacts(account), requests: this.requests(account), revision: this.revision() }
173
+ if (input.action === 'contact' || input.action === 'request') return this.changeRelation(account, client, input)
174
+ accountId(account)
175
+ const key = roomKey(input.key)
176
+ if (input.action === 'remove' && key === 'room:1') throw new Error('广场不能移除')
177
+ const event = this.transaction(() => {
178
+ const revision = this.nextRevision()
179
+ let pendingIds = []
180
+ if (input.action === 'session') this.ensureSession(account, { ...input.session, key })
181
+ else if (input.action === 'draft') this.db.prepare('UPDATE sessions SET draft=? WHERE account=? AND session_key=?').run(text(input.draft, 2000), account, key)
182
+ else if (input.action === 'read') this.db.prepare('UPDATE sessions SET unread=0 WHERE account=? AND session_key=?').run(account, key)
183
+ else if (input.action === 'delete') {
184
+ const id = text(input.id, 8192)
185
+ if (!id) throw new Error('消息标识无效')
186
+ this.db.prepare('INSERT OR IGNORE INTO message_tombstones VALUES (?,?,?)').run(account, key, id)
187
+ this.db.prepare('DELETE FROM messages WHERE account=? AND session_key=? AND message_id=?').run(account, key, id)
188
+ this.refreshPreview(account, key)
189
+ } else if (input.action === 'clear' || input.action === 'remove') {
190
+ pendingIds = this.db.prepare("SELECT message_id FROM messages WHERE account=? AND session_key=? AND status IN ('uploading','sending')").all(account, key).map(row => row.message_id)
191
+ this.db.prepare('INSERT OR IGNORE INTO message_tombstones SELECT account,session_key,message_id FROM messages WHERE account=? AND session_key=?').run(account, key)
192
+ this.db.prepare('DELETE FROM messages WHERE account=? AND session_key=?').run(account, key)
193
+ this.db.prepare("UPDATE sessions SET preview='',updated=0,unread=0 WHERE account=? AND session_key=?").run(account, key)
194
+ if (input.action === 'remove') this.db.prepare('DELETE FROM sessions WHERE account=? AND session_key=?').run(account, key)
195
+ } else throw new Error('存储操作无效')
196
+ return { type: input.action, account, key, id: input.id, pendingIds, session: this.session(account, key), revision }
197
+ })
198
+ return { event: this.emit(event, client) }
199
+ }
200
+ changeRelation(account, client, input) {
201
+ const now = Date.now(), id = text(input.userId || input.peerId, 512), action = input.action
202
+ if (!id) throw new Error('用户标识无效')
203
+ const event = this.transaction(() => {
204
+ const revision = this.nextRevision()
205
+ if (action === 'contact') {
206
+ const relation = ['friend', 'blocked', 'none'].includes(input.relation) ? input.relation : ''
207
+ if (!relation) throw new Error('联系人关系无效')
208
+ if (relation === 'none') this.db.prepare('DELETE FROM contacts WHERE account=? AND user_id=?').run(account, id)
209
+ else this.db.prepare(`INSERT INTO contacts(account,user_id,nickname,avatar,relation,created,updated) VALUES (?,?,?,?,?,?,?)
210
+ ON CONFLICT(account,user_id) DO UPDATE SET nickname=CASE WHEN excluded.nickname<>'' THEN excluded.nickname ELSE contacts.nickname END,
211
+ avatar=CASE WHEN excluded.avatar<>'' THEN excluded.avatar ELSE contacts.avatar END,relation=excluded.relation,updated=excluded.updated`).run(account,id,text(input.nickname),text(input.avatar,2048),relation,now,now)
212
+ } else {
213
+ const requestId = text(input.requestId, 200); const peerId = text(input.peerId || id, 512)
214
+ if (!requestId) throw new Error('申请标识无效')
215
+ const status = ['pending','accepted','rejected','released'].includes(input.status) ? input.status : ''
216
+ const direction = input.direction === 'incoming' ? 'incoming' : 'outgoing'
217
+ if (!status) throw new Error('申请状态无效')
218
+ this.db.prepare(`INSERT INTO friend_requests(account,request_id,peer_id,direction,status,nickname,avatar,created,updated) VALUES (?,?,?,?,?,?,?,?,?)
219
+ ON CONFLICT(account,request_id) DO UPDATE SET status=excluded.status,nickname=CASE WHEN excluded.nickname<>'' THEN excluded.nickname ELSE friend_requests.nickname END,
220
+ avatar=CASE WHEN excluded.avatar<>'' THEN excluded.avatar ELSE friend_requests.avatar END,updated=excluded.updated`).run(account,requestId,peerId,direction,status,text(input.nickname),text(input.avatar,2048),Number(input.created)||now,now)
221
+ if (status === 'accepted') this.db.prepare(`INSERT INTO contacts(account,user_id,nickname,avatar,relation,created,updated) VALUES (?,?,?,?,?,?,?)
222
+ ON CONFLICT(account,user_id) DO UPDATE SET relation='friend',updated=excluded.updated`).run(account,peerId,text(input.nickname),text(input.avatar,2048),'friend',now,now)
223
+ }
224
+ return { type: action, account, userId: id, requestId: input.requestId, relation: input.relation, status: input.status, contacts: this.contacts(account), requests: this.requests(account), revision }
225
+ })
226
+ return { event: this.emit(event, client), contacts: event.contacts, requests: event.requests }
227
+ }
228
+ endClient(account, client) {
229
+ const rows = this.db.prepare("SELECT * FROM messages WHERE account=? AND owner=? AND status IN ('uploading','sending')").all(account, client)
230
+ for (const row of rows) {
231
+ const entry = this.entry(row)
232
+ entry.sendError = entry.sendStatus === 'uploading' ? '图片上传未完成' : '发送未完成,未自动重发'
233
+ entry.sendStatus = 'failed'
234
+ this.put(account, client, { key: row.session_key, entry })
235
+ }
236
+ }
237
+ close() { this.listeners.clear(); this.db.close() }
238
+ }
package/host.js ADDED
@@ -0,0 +1,67 @@
1
+ import { registerHistory } from "./history-host.js"
2
+ import { registerMedia } from "./media.js"
3
+ import { readFileSync } from 'node:fs'
4
+ import { AccountService, profileCredentialKey } from './login.js'
5
+
6
+ export const name = '@yiarce/dsh-chat'
7
+ export const inject = ['credentials', 'connection']
8
+
9
+ export function apply(ctx) {
10
+ registerMedia(ctx)
11
+ registerHistory(ctx)
12
+ const key = profileCredentialKey(process.env.DSH_PROFILE_DIR || process.env.DSH_PROFILE || 'default')
13
+ const service = new AccountService(ctx.credentials, { key })
14
+ const restored = service.restore()
15
+ ctx.effect(() => () => service.dispose())
16
+ const json = (value, status = 200) => Response.json(value, { status, headers: { 'Cache-Control': 'no-store' } })
17
+ const assets = [
18
+ ["history-client.js", "history-client.js", "text/javascript; charset=utf-8"],
19
+ ["history-model.js", "history-model.js", "text/javascript; charset=utf-8"],
20
+ ["window-client.js", "window-client.js", "text/javascript; charset=utf-8"],
21
+ ["media-client.js", "media-client.js", "text/javascript; charset=utf-8"],
22
+ ["standalone.js", "standalone.js", "text/javascript; charset=utf-8"],
23
+ ["appearance.js", "appearance.js", "text/javascript; charset=utf-8"],
24
+ ["context-menu.js", "context-menu.js", "text/javascript; charset=utf-8"],
25
+ ['page', 'chat.html', 'text/html; charset=utf-8'],
26
+ ['page.js', 'page.js', 'text/javascript; charset=utf-8'],
27
+ ['page.css', 'page.css', 'text/css; charset=utf-8'],
28
+ ]
29
+ for (const [path, file, type] of assets) {
30
+ const body = readFileSync(new URL(file, import.meta.url))
31
+ ctx.effect(() => ctx.connection.fetch.register({
32
+ path: '/api/yiarce-chat/' + path, methods: ['GET'], requestBody: 'buffered',
33
+ fetch: () => new Response(body, { headers: { 'Content-Type': type, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } }),
34
+ }))
35
+ }
36
+ ctx.effect(() => ctx.connection.fetch.register({
37
+ path: '/api/yiarce-chat/login', methods: ['GET', 'POST'], requestBody: 'buffered',
38
+ async fetch(request) {
39
+ try {
40
+ await restored
41
+ if (request.method === 'GET') return json(service.view())
42
+ let input
43
+ try { input = await request.json() } catch { return json({ error: 'invalid-request' }, 400) }
44
+ switch (input?.action) {
45
+ case 'begin': return json(service.beginSignIn())
46
+ case 'cancel':
47
+ if (typeof input.id !== 'string' || !input.id) return json({ error: 'invalid-request' }, 400)
48
+ return json(service.cancelAttempt(input.id))
49
+ case 'restore': return json(await service.restore())
50
+ case 'credential': {
51
+ const { token } = await service.chatCredential()
52
+ const server = new URL(process.env.CHAT_SERVER || 'http://101.35.239.136:34119')
53
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(server.protocol) || server.username || server.password) throw new Error('invalid-server')
54
+ server.protocol = ['https:', 'wss:'].includes(server.protocol) ? 'wss:' : 'ws:'
55
+ server.pathname = server.pathname.replace(/\/$/, '') + '/ws'
56
+ server.search = ''; server.hash = ''
57
+ server.searchParams.set('token', token)
58
+ return json({ wsUrl: server.href })
59
+ }
60
+ default: return json({ error: 'invalid-request' }, 400)
61
+ }
62
+ } catch (error) {
63
+ return json({ error: error.code || error.message || 'storage' }, error.code === 'auth-expired' ? 401 : 503)
64
+ }
65
+ },
66
+ }))
67
+ }