koishi-plugin-chat-patch 5.0.3 → 5.3.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/client/web/dist/assets/Chat-B448YOeN.js +28 -0
- package/client/web/dist/assets/{MsgBody-LNGNGtE3.js → MsgBody-DDacjWJ9.js} +1 -1
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-B1dWyCB0.js +67 -0
- package/client/web/dist/assets/{index-CwyyQN13.js → index-BlLut5xn.js} +121 -129
- package/client/web/dist/assets/index-fW_zcub3.css +1 -0
- package/client/web/dist/assets/pinyin-Dsq98249.js +157657 -0
- package/client/web/dist/index.html +2 -2
- package/client/web/dist/notice_local.json +1 -0
- package/client/web/package-lock.json +49 -0
- package/client/web/package.json +1 -0
- package/client/web/public/notice_local.json +1 -0
- package/client/web/src/components/EmojiFace.vue +0 -6
- package/client/web/src/components/FacePan.vue +0 -12
- package/client/web/src/components/MsgBody.vue +3 -4
- package/client/web/src/components/SettingsTab.vue +133 -0
- package/client/web/src/function/connect.ts +42 -21
- package/client/web/src/function/model/emoji.ts +7 -67
- package/client/web/src/function/msg.ts +74 -23
- package/client/web/src/function/satori-model.ts +4 -3
- package/client/web/src/function/utils/appUtil.ts +1 -4
- package/client/web/src/function/utils/msgUtil.ts +121 -87
- package/client/web/src/function/utils/pinyin.ts +25 -65
- package/client/web/src/function/utils/sessionUtil.ts +107 -9
- package/client/web/src/pages/Friends.vue +49 -13
- package/client/web/src/pages/Messages.vue +45 -14
- package/client/web/src/pages/Options.vue +4 -4
- package/client/web/src/pages/options/OptDev.vue +1 -14
- package/package.json +1 -1
- package/src/web.ts +712 -712
- package/client/web/dist/assets/Chat-CXjwWCiV.js +0 -28
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BK5okBfM.js +0 -67
- package/client/web/dist/assets/index-BgOX_tNa.css +0 -1
package/src/web.ts
CHANGED
|
@@ -1,712 +1,712 @@
|
|
|
1
|
-
import { Context } from 'koishi'
|
|
2
|
-
import {} from '@koishijs/plugin-server'
|
|
3
|
-
import { createReadStream, existsSync, promises as fs, statSync } from 'node:fs'
|
|
4
|
-
import { createHash } from 'node:crypto'
|
|
5
|
-
import path from 'node:path'
|
|
6
|
-
import { pathToFileURL } from 'node:url'
|
|
7
|
-
import send from 'koa-send'
|
|
8
|
-
import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
|
|
9
|
-
import FileType from 'file-type'
|
|
10
|
-
|
|
11
|
-
import { Config } from './config'
|
|
12
|
-
import { ContactCacheService } from './cache'
|
|
13
|
-
import { ChatDatabase } from './database'
|
|
14
|
-
import { PluginLogger } from './logger'
|
|
15
|
-
import { ContactCacheItem, SelfMessagePayload, SelfMessageRecord } from './types'
|
|
16
|
-
|
|
17
|
-
interface ViteConsoleServer {
|
|
18
|
-
config?: {
|
|
19
|
-
server?: {
|
|
20
|
-
preTransformRequests?: boolean
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
transformIndexHtml(url: string, html: string, originalUrl?: string): Promise<string>
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ViteConsoleLike {
|
|
27
|
-
vite?: ViteConsoleServer
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function registerWeb(
|
|
31
|
-
ctx: Context,
|
|
32
|
-
config: Config,
|
|
33
|
-
database: ChatDatabase,
|
|
34
|
-
contactCache: ContactCacheService,
|
|
35
|
-
logger: PluginLogger,
|
|
36
|
-
) {
|
|
37
|
-
const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
|
|
38
|
-
const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
|
|
39
|
-
const webPublic = path.resolve(__dirname, '..', 'client', 'web', 'public')
|
|
40
|
-
const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
|
|
41
|
-
const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
|
|
42
|
-
|
|
43
|
-
type WebContext = ParameterizedContext<DefaultState, DefaultContext>
|
|
44
|
-
const cacheType = (type: string) => type === 'user' ? 'friend' : type
|
|
45
|
-
|
|
46
|
-
const getVite = (): ViteConsoleServer | undefined => {
|
|
47
|
-
return (ctx.console as unknown as ViteConsoleLike).vite
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const viteFsUrl = (target: string): string => {
|
|
51
|
-
return `/vite/@fs/${path.resolve(target).split(path.sep).join('/')}`
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const transformWebIndex = async (vite: ViteConsoleServer, requestPath: string) => {
|
|
55
|
-
// 源码由 iframe 按需加载,避免 Koishi Vite 启动时提前转换整个 web 应用
|
|
56
|
-
if (vite.config?.server) vite.config.server.preTransformRequests = false
|
|
57
|
-
const mainFsUrl = `/@fs/${path.resolve(webSource, 'main.ts').split(path.sep).join('/')}`
|
|
58
|
-
const html = (await fs.readFile(webIndex, 'utf8')).replace(
|
|
59
|
-
'src="/src/main.ts"',
|
|
60
|
-
`src="${mainFsUrl}"`,
|
|
61
|
-
)
|
|
62
|
-
let transformed = await vite.transformIndexHtml('/chat-patch/web/index.html', html, requestPath)
|
|
63
|
-
transformed = transformed
|
|
64
|
-
.replace(/\/vite\/bcui/g, viteFsUrl(path.join(webPublic, 'bcui')))
|
|
65
|
-
.replace(/\/vite\/css/g, viteFsUrl(path.join(webPublic, 'css')))
|
|
66
|
-
return transformed
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const servePublicFile = async (koa: WebContext, prefix: string) => {
|
|
70
|
-
const relative = (koa.params?.[0] ?? '').replace(/^\/+/, '')
|
|
71
|
-
const fullPath = path.resolve(webPublic, prefix, relative)
|
|
72
|
-
if (!fullPath.startsWith(webPublic + path.sep)) {
|
|
73
|
-
koa.status = 403
|
|
74
|
-
koa.body = ''
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
|
|
78
|
-
await send(koa, path.relative(webPublic, fullPath), { root: webPublic })
|
|
79
|
-
return
|
|
80
|
-
}
|
|
81
|
-
koa.status = 404
|
|
82
|
-
koa.body = ''
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// web 应用里仍有 /img 这类运行时绝对路径,开发模式直接由 Koishi 服务托管
|
|
86
|
-
const registerDevPublic = (pattern: string, prefix: string) => {
|
|
87
|
-
ctx.server.get(pattern, async (koa: WebContext, next: () => Promise<void>) => {
|
|
88
|
-
if (!getVite()) return next()
|
|
89
|
-
await servePublicFile(koa, prefix)
|
|
90
|
-
})
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
registerDevPublic('/img(.*)', 'img')
|
|
94
|
-
|
|
95
|
-
const toExtension = (value: string): string => {
|
|
96
|
-
const ext = value.startsWith('.') ? value : `.${value}`
|
|
97
|
-
return ext.toLowerCase()
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const normalizeGroupId = (value: string): string => {
|
|
101
|
-
const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
|
|
102
|
-
const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
|
|
103
|
-
return wrapped ? wrapped[1] : raw || value
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const historyChannelCandidates = (channelId: string): string[] => {
|
|
107
|
-
const raw = normalizeGroupId(channelId)
|
|
108
|
-
const candidates = [channelId]
|
|
109
|
-
if (raw && raw !== channelId) candidates.push(raw)
|
|
110
|
-
const id = raw || channelId
|
|
111
|
-
if (!/^(?:group|room|chat|channel|guild|private):/i.test(channelId)) {
|
|
112
|
-
candidates.push(`group:${id}`, `private:${id}`)
|
|
113
|
-
}
|
|
114
|
-
if (id) {
|
|
115
|
-
candidates.push(` [_${id}_] `, `_${id}_`, `[${id}]`, `group:${id}`)
|
|
116
|
-
}
|
|
117
|
-
return [...new Set(candidates)]
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const mimeToExt: Record<string, string> = {
|
|
121
|
-
'audio/mpeg': '.mp3',
|
|
122
|
-
'audio/mp3': '.mp3',
|
|
123
|
-
'audio/mp4': '.m4a',
|
|
124
|
-
'audio/x-m4a': '.m4a',
|
|
125
|
-
'audio/wav': '.wav',
|
|
126
|
-
'audio/x-wav': '.wav',
|
|
127
|
-
'audio/webm': '.webm',
|
|
128
|
-
'audio/ogg': '.ogg',
|
|
129
|
-
'audio/aac': '.aac',
|
|
130
|
-
'audio/flac': '.flac',
|
|
131
|
-
'image/jpeg': '.jpg',
|
|
132
|
-
'image/png': '.png',
|
|
133
|
-
'image/gif': '.gif',
|
|
134
|
-
'image/webp': '.webp',
|
|
135
|
-
'video/mp4': '.mp4',
|
|
136
|
-
'video/webm': '.webm',
|
|
137
|
-
'video/quicktime': '.mov',
|
|
138
|
-
'video/x-msvideo': '.avi',
|
|
139
|
-
'video/x-matroska': '.mkv',
|
|
140
|
-
'video/x-flv': '.flv',
|
|
141
|
-
'video/3gpp': '.3gp',
|
|
142
|
-
'video/ogg': '.ogv',
|
|
143
|
-
'video/mp2t': '.ts',
|
|
144
|
-
}
|
|
145
|
-
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
|
|
146
|
-
|
|
147
|
-
ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
|
|
148
|
-
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
149
|
-
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
150
|
-
let source = String(body.dataUrl ?? body.data ?? '')
|
|
151
|
-
let name = String(body.name ?? '')
|
|
152
|
-
let mime = ''
|
|
153
|
-
let buffer: Buffer | null = null
|
|
154
|
-
if (source) {
|
|
155
|
-
if (source.startsWith('base64://')) source = source.slice(9)
|
|
156
|
-
const comma = source.indexOf('base64,')
|
|
157
|
-
const base64 = comma >= 0 ? source.slice(comma + 7) : source
|
|
158
|
-
const normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
|
|
159
|
-
try {
|
|
160
|
-
buffer = Buffer.from(normalizedBase64, 'base64')
|
|
161
|
-
} catch {
|
|
162
|
-
koa.status = 400
|
|
163
|
-
koa.body = { error: 'invalid media data' }
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
mime = source.startsWith('data:')
|
|
167
|
-
? source.slice(5, source.indexOf(';')).toLowerCase()
|
|
168
|
-
: ''
|
|
169
|
-
} else {
|
|
170
|
-
const chunks: Buffer[] = []
|
|
171
|
-
for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
|
|
172
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
173
|
-
}
|
|
174
|
-
buffer = Buffer.concat(chunks)
|
|
175
|
-
name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
|
|
176
|
-
try {
|
|
177
|
-
name = decodeURIComponent(name)
|
|
178
|
-
} catch {
|
|
179
|
-
// 保持原始名称即可
|
|
180
|
-
}
|
|
181
|
-
mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
|
|
182
|
-
}
|
|
183
|
-
if (!buffer || !buffer.length) {
|
|
184
|
-
koa.status = 400
|
|
185
|
-
koa.body = { error: 'missing media data' }
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
if (buffer.length > MAX_UPLOAD_BYTES) {
|
|
189
|
-
koa.status = 413
|
|
190
|
-
koa.body = { error: 'media file too large' }
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
if (!buffer.length) {
|
|
194
|
-
koa.status = 400
|
|
195
|
-
koa.body = { error: 'invalid media data' }
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
await fs.mkdir(uploadDir, { recursive: true })
|
|
200
|
-
let detected: { ext?: string } | undefined
|
|
201
|
-
try {
|
|
202
|
-
const result = await FileType.fromBuffer(buffer)
|
|
203
|
-
detected = result ?? undefined
|
|
204
|
-
} catch {
|
|
205
|
-
// 部分音频/文件无法识别时继续用 MIME 或文件名兜底
|
|
206
|
-
detected = undefined
|
|
207
|
-
}
|
|
208
|
-
const mimeExt = mimeToExt[mime] || ''
|
|
209
|
-
const nameExt = path.extname(name).toLowerCase()
|
|
210
|
-
const ext = detected?.ext
|
|
211
|
-
? toExtension(detected.ext)
|
|
212
|
-
: mimeExt || nameExt || '.bin'
|
|
213
|
-
const filename = `${createHash('md5').update(buffer).digest('hex')}${ext}`
|
|
214
|
-
const filePath = path.join(uploadDir, filename)
|
|
215
|
-
if (!existsSync(filePath)) {
|
|
216
|
-
await fs.writeFile(filePath, buffer)
|
|
217
|
-
}
|
|
218
|
-
koa.body = {
|
|
219
|
-
path: pathToFileURL(filePath).href,
|
|
220
|
-
localPath: filePath,
|
|
221
|
-
}
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
|
|
225
|
-
const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
|
|
226
|
-
if (!fileName || fileName === '.' || fileName === '..') {
|
|
227
|
-
koa.status = 400
|
|
228
|
-
koa.body = { error: 'missing media file' }
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
const filePath = path.resolve(uploadDir, fileName)
|
|
232
|
-
const root = path.resolve(uploadDir)
|
|
233
|
-
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
|
234
|
-
koa.status = 400
|
|
235
|
-
koa.body = { error: 'invalid media file' }
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
239
|
-
koa.status = 404
|
|
240
|
-
koa.body = { error: 'media file not found' }
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
const size = statSync(filePath).size
|
|
244
|
-
koa.set('Accept-Ranges', 'bytes')
|
|
245
|
-
if (fileName.toLowerCase().endsWith('.webm')) {
|
|
246
|
-
koa.type = 'audio/webm'
|
|
247
|
-
} else {
|
|
248
|
-
koa.type = fileName
|
|
249
|
-
}
|
|
250
|
-
const range = koa.headers.range
|
|
251
|
-
if (range) {
|
|
252
|
-
const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
|
|
253
|
-
if (!match) {
|
|
254
|
-
koa.status = 416
|
|
255
|
-
koa.set('Content-Range', `bytes */${size}`)
|
|
256
|
-
koa.body = ''
|
|
257
|
-
return
|
|
258
|
-
}
|
|
259
|
-
let start = 0
|
|
260
|
-
let end = size - 1
|
|
261
|
-
if (match[1] === '' && match[2]) {
|
|
262
|
-
start = Math.max(0, size - Number(match[2]))
|
|
263
|
-
} else if (match[1]) {
|
|
264
|
-
start = Number(match[1])
|
|
265
|
-
end = match[2] ? Number(match[2]) : end
|
|
266
|
-
}
|
|
267
|
-
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
|
|
268
|
-
koa.status = 416
|
|
269
|
-
koa.set('Content-Range', `bytes */${size}`)
|
|
270
|
-
koa.body = ''
|
|
271
|
-
return
|
|
272
|
-
}
|
|
273
|
-
if (end >= size) end = size - 1
|
|
274
|
-
if (end < start) end = start
|
|
275
|
-
koa.status = 206
|
|
276
|
-
koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
277
|
-
koa.set('Content-Length', String(end - start + 1))
|
|
278
|
-
koa.body = createReadStream(filePath, { start, end })
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
koa.set('Content-Length', String(size))
|
|
282
|
-
koa.body = createReadStream(filePath)
|
|
283
|
-
})
|
|
284
|
-
|
|
285
|
-
ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
|
|
286
|
-
const entries = await database.getAllContacts()
|
|
287
|
-
const botMap = new Map<string, {
|
|
288
|
-
platform: string
|
|
289
|
-
selfId: string
|
|
290
|
-
groups: ContactCacheItem[]
|
|
291
|
-
friends: ContactCacheItem[]
|
|
292
|
-
}>()
|
|
293
|
-
for (const entry of entries) {
|
|
294
|
-
const key = JSON.stringify([entry.platform, entry.selfId])
|
|
295
|
-
const bot = botMap.get(key) ?? {
|
|
296
|
-
platform: entry.platform,
|
|
297
|
-
selfId: entry.selfId,
|
|
298
|
-
groups: [],
|
|
299
|
-
friends: [],
|
|
300
|
-
}
|
|
301
|
-
if (entry.type === 'group') bot.groups = entry.contacts
|
|
302
|
-
if (entry.type === 'friend') bot.friends = entry.contacts
|
|
303
|
-
botMap.set(key, bot)
|
|
304
|
-
}
|
|
305
|
-
koa.body = { bots: [...botMap.values()] }
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
ctx.server.get(`${config.basePath}/api/history`, async (koa) => {
|
|
309
|
-
const platform = String(koa.query.platform ?? '')
|
|
310
|
-
const selfId = String(koa.query.selfId ?? '')
|
|
311
|
-
const channelId = String(koa.query.channelId ?? '')
|
|
312
|
-
if (!platform || !selfId || !channelId) {
|
|
313
|
-
koa.status = 400
|
|
314
|
-
koa.body = { error: 'missing history params' }
|
|
315
|
-
return
|
|
316
|
-
}
|
|
317
|
-
const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel)
|
|
318
|
-
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel
|
|
319
|
-
const beforeTime = Number(koa.query.beforeTime ?? 0)
|
|
320
|
-
const queryMessages = async (cid: string) => {
|
|
321
|
-
return Number.isFinite(beforeTime) && beforeTime > 0
|
|
322
|
-
? database.listMessagesBefore(platform, selfId, cid, beforeTime, limit)
|
|
323
|
-
: database.listMessages(platform, selfId, cid, limit)
|
|
324
|
-
}
|
|
325
|
-
let messages: Awaited<ReturnType<typeof queryMessages>> = []
|
|
326
|
-
for (const candidate of historyChannelCandidates(channelId)) {
|
|
327
|
-
messages = await queryMessages(candidate)
|
|
328
|
-
if (messages.length) break
|
|
329
|
-
}
|
|
330
|
-
let selfMessages: SelfMessageRecord[] = []
|
|
331
|
-
const querySelfMessages = async (cid: string): Promise<SelfMessageRecord[]> => {
|
|
332
|
-
return Number.isFinite(beforeTime) && beforeTime > 0
|
|
333
|
-
? database.listSelfMessagesBefore(platform, selfId, cid, beforeTime, limit)
|
|
334
|
-
: database.listSelfMessages(platform, selfId, cid, limit)
|
|
335
|
-
}
|
|
336
|
-
for (const candidate of historyChannelCandidates(channelId)) {
|
|
337
|
-
selfMessages = await querySelfMessages(candidate)
|
|
338
|
-
if (selfMessages.length) break
|
|
339
|
-
}
|
|
340
|
-
// 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
|
|
341
|
-
messages.sort((a, b) => {
|
|
342
|
-
const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
|
|
343
|
-
const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
|
|
344
|
-
return timeA - timeB
|
|
345
|
-
})
|
|
346
|
-
selfMessages.sort((a, b) => a.sentAt - b.sentAt)
|
|
347
|
-
koa.body = { messages, selfMessages }
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
|
|
351
|
-
const platform = String(koa.query.platform ?? '')
|
|
352
|
-
const selfId = String(koa.query.selfId ?? '')
|
|
353
|
-
const channelId = String(koa.query.channelId ?? '')
|
|
354
|
-
if (!platform || !selfId || !channelId) {
|
|
355
|
-
koa.status = 400
|
|
356
|
-
koa.body = { error: 'missing self-message params' }
|
|
357
|
-
return
|
|
358
|
-
}
|
|
359
|
-
const parsedLimit = Number(koa.query.limit ?? config.historyPageSize)
|
|
360
|
-
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.historyPageSize
|
|
361
|
-
const beforeTime = Number(koa.query.beforeTime ?? 0)
|
|
362
|
-
let messages: SelfMessageRecord[] = []
|
|
363
|
-
for (const candidate of historyChannelCandidates(channelId)) {
|
|
364
|
-
messages = Number.isFinite(beforeTime) && beforeTime > 0
|
|
365
|
-
? await database.listSelfMessagesBefore(platform, selfId, candidate, beforeTime, limit)
|
|
366
|
-
: await database.listSelfMessages(platform, selfId, candidate, limit)
|
|
367
|
-
if (messages.length) break
|
|
368
|
-
}
|
|
369
|
-
messages.sort((a, b) => a.sentAt - b.sentAt)
|
|
370
|
-
koa.body = { messages }
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
ctx.server.post(`${config.basePath}/api/self-messages`, async (koa) => {
|
|
374
|
-
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
375
|
-
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
376
|
-
const payload: SelfMessagePayload = {
|
|
377
|
-
id: typeof body.id === 'string' ? body.id : undefined,
|
|
378
|
-
platform: String(body.platform ?? ''),
|
|
379
|
-
selfId: String(body.selfId ?? ''),
|
|
380
|
-
channelId: String(body.channelId ?? ''),
|
|
381
|
-
guildId: typeof body.guildId === 'string' ? body.guildId : undefined,
|
|
382
|
-
channelType: body.channelType === 'user' ? 'user' : 'group',
|
|
383
|
-
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
384
|
-
content: typeof body.content === 'string' ? body.content : undefined,
|
|
385
|
-
elements: Array.isArray(body.elements) ? body.elements as unknown[] : undefined,
|
|
386
|
-
message: Array.isArray(body.message) ? body.message as unknown[] : undefined,
|
|
387
|
-
forwardId: typeof body.forwardId === 'string' ? body.forwardId : undefined,
|
|
388
|
-
forwardContent: Array.isArray(body.forwardContent) ? body.forwardContent as unknown[] : undefined,
|
|
389
|
-
sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
|
|
390
|
-
? body.sentAt
|
|
391
|
-
: Date.now(),
|
|
392
|
-
sequence: typeof body.sequence === 'number' && Number.isFinite(body.sequence)
|
|
393
|
-
? body.sequence
|
|
394
|
-
: 0,
|
|
395
|
-
source: body.source === 'plugin' ? 'plugin' : 'webui',
|
|
396
|
-
kind: typeof body.kind === 'string' ? body.kind : 'text',
|
|
397
|
-
}
|
|
398
|
-
if (!payload.platform || !payload.selfId || !payload.channelId) {
|
|
399
|
-
koa.status = 400
|
|
400
|
-
koa.body = { error: 'missing self-message params' }
|
|
401
|
-
return
|
|
402
|
-
}
|
|
403
|
-
const record: SelfMessageRecord = {
|
|
404
|
-
id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
405
|
-
platform: payload.platform,
|
|
406
|
-
selfId: payload.selfId,
|
|
407
|
-
channelId: payload.channelId,
|
|
408
|
-
guildId: payload.guildId,
|
|
409
|
-
channelType: payload.channelType,
|
|
410
|
-
messageId: payload.messageId,
|
|
411
|
-
content: payload.content,
|
|
412
|
-
elements: payload.elements,
|
|
413
|
-
message: payload.message,
|
|
414
|
-
forwardId: payload.forwardId,
|
|
415
|
-
forwardContent: payload.forwardContent,
|
|
416
|
-
sentAt: payload.sentAt ?? Date.now(),
|
|
417
|
-
sequence: payload.sequence ?? 0,
|
|
418
|
-
source: payload.source ?? 'webui',
|
|
419
|
-
kind: payload.kind ?? 'text',
|
|
420
|
-
}
|
|
421
|
-
await database.upsertSelfMessage(record)
|
|
422
|
-
koa.body = { ok: true, message: record }
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
ctx.server.get(`${config.basePath}/api/forward`, async (koa) => {
|
|
426
|
-
const platform = String(koa.query.platform ?? '')
|
|
427
|
-
const selfId = String(koa.query.selfId ?? '')
|
|
428
|
-
const channelId = String(koa.query.channelId ?? '')
|
|
429
|
-
const id = String(koa.query.id ?? '')
|
|
430
|
-
if (!platform || !selfId || !id) {
|
|
431
|
-
koa.status = 400
|
|
432
|
-
koa.body = { error: 'missing forward params' }
|
|
433
|
-
return
|
|
434
|
-
}
|
|
435
|
-
if (channelId) {
|
|
436
|
-
const cached = await database.findSelfForwardContent(platform, selfId, channelId, id)
|
|
437
|
-
if (cached) {
|
|
438
|
-
koa.body = cached
|
|
439
|
-
return
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
if (platform !== 'onebot') {
|
|
443
|
-
koa.status = 501
|
|
444
|
-
koa.body = { error: 'forward api not supported' }
|
|
445
|
-
return
|
|
446
|
-
}
|
|
447
|
-
const bot = ctx.bots.find((item) => {
|
|
448
|
-
return item.platform === platform && item.selfId === selfId
|
|
449
|
-
})
|
|
450
|
-
if (!bot) {
|
|
451
|
-
koa.status = 404
|
|
452
|
-
koa.body = { error: 'bot not found' }
|
|
453
|
-
return
|
|
454
|
-
}
|
|
455
|
-
const internal = (bot as unknown as {
|
|
456
|
-
internal?: { getForwardMsg?: (messageId: string) => Promise<unknown> }
|
|
457
|
-
}).internal
|
|
458
|
-
if (!internal?.getForwardMsg) {
|
|
459
|
-
koa.status = 501
|
|
460
|
-
koa.body = { error: 'forward api not supported' }
|
|
461
|
-
return
|
|
462
|
-
}
|
|
463
|
-
try {
|
|
464
|
-
koa.body = await internal.getForwardMsg(id)
|
|
465
|
-
} catch (error) {
|
|
466
|
-
koa.status = 502
|
|
467
|
-
koa.body = { error: 'forward api failed' }
|
|
468
|
-
}
|
|
469
|
-
})
|
|
470
|
-
|
|
471
|
-
ctx.server.post(`${config.basePath}/api/send-forward`, async (koa) => {
|
|
472
|
-
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
473
|
-
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
474
|
-
const platform = String(body.platform ?? '')
|
|
475
|
-
const selfId = String(body.selfId ?? '')
|
|
476
|
-
const type = String(body.type ?? '')
|
|
477
|
-
const id = String(body.id ?? '')
|
|
478
|
-
if (!platform || !selfId || !type || !id) {
|
|
479
|
-
koa.status = 400
|
|
480
|
-
koa.body = { error: 'missing send-forward params' }
|
|
481
|
-
return
|
|
482
|
-
}
|
|
483
|
-
if (platform !== 'onebot') {
|
|
484
|
-
koa.status = 501
|
|
485
|
-
koa.body = { error: 'forward api not supported' }
|
|
486
|
-
return
|
|
487
|
-
}
|
|
488
|
-
const bot = ctx.bots.find((item) => {
|
|
489
|
-
return item.platform === platform && item.selfId === selfId
|
|
490
|
-
})
|
|
491
|
-
if (!bot) {
|
|
492
|
-
koa.status = 404
|
|
493
|
-
koa.body = { error: 'bot not found' }
|
|
494
|
-
return
|
|
495
|
-
}
|
|
496
|
-
const internal = (bot as unknown as {
|
|
497
|
-
internal?: {
|
|
498
|
-
sendGroupForwardMsg?: (groupId: string, messages: unknown[]) => Promise<unknown>
|
|
499
|
-
sendPrivateForwardMsg?: (userId: string, messages: unknown[]) => Promise<unknown>
|
|
500
|
-
}
|
|
501
|
-
}).internal
|
|
502
|
-
if (type === 'group' && !internal?.sendGroupForwardMsg) {
|
|
503
|
-
koa.status = 501
|
|
504
|
-
koa.body = { error: 'forward api not supported' }
|
|
505
|
-
return
|
|
506
|
-
}
|
|
507
|
-
if (type !== 'group' && !internal?.sendPrivateForwardMsg) {
|
|
508
|
-
koa.status = 501
|
|
509
|
-
koa.body = { error: 'forward api not supported' }
|
|
510
|
-
return
|
|
511
|
-
}
|
|
512
|
-
const messages = Array.isArray(body.messages) ? body.messages as unknown[] : []
|
|
513
|
-
const nodes = messages.map((raw) => {
|
|
514
|
-
const item = typeof raw === 'object' && raw !== null
|
|
515
|
-
? raw as Record<string, unknown>
|
|
516
|
-
: {}
|
|
517
|
-
const nodeData = typeof item.data === 'object' && item.data !== null
|
|
518
|
-
? item.data as Record<string, unknown>
|
|
519
|
-
: item
|
|
520
|
-
const content = Array.isArray(item.content)
|
|
521
|
-
? item.content as unknown[]
|
|
522
|
-
: Array.isArray(nodeData.content)
|
|
523
|
-
? nodeData.content as unknown[]
|
|
524
|
-
: []
|
|
525
|
-
const userId = String(item.user_id ?? nodeData.user_id ?? nodeData.uin ?? '')
|
|
526
|
-
const nickname = String(item.nickname ?? nodeData.nickname ?? nodeData.name ?? '')
|
|
527
|
-
return {
|
|
528
|
-
type: 'node',
|
|
529
|
-
data: {
|
|
530
|
-
user_id: userId,
|
|
531
|
-
nickname,
|
|
532
|
-
// koishi 的 OneBot adapter 发送自定义转发节点时使用 uin/name
|
|
533
|
-
uin: userId,
|
|
534
|
-
name: nickname,
|
|
535
|
-
time: String(item.time ?? nodeData.time ?? Math.floor(Date.now() / 1000)),
|
|
536
|
-
content: content.map((segmentRaw) => {
|
|
537
|
-
const segment = typeof segmentRaw === 'object' && segmentRaw !== null
|
|
538
|
-
? segmentRaw as Record<string, unknown>
|
|
539
|
-
: {}
|
|
540
|
-
const copy = { ...segment }
|
|
541
|
-
const segmentType = String(copy.type ?? 'text')
|
|
542
|
-
delete copy.type
|
|
543
|
-
return { type: segmentType, data: copy }
|
|
544
|
-
}),
|
|
545
|
-
},
|
|
546
|
-
}
|
|
547
|
-
})
|
|
548
|
-
try {
|
|
549
|
-
// 必须从 internal 上直接调用,避免方法被取出后丢失 this
|
|
550
|
-
koa.body = type === 'group'
|
|
551
|
-
? await internal?.sendGroupForwardMsg?.(id, nodes)
|
|
552
|
-
: await internal?.sendPrivateForwardMsg?.(id, nodes)
|
|
553
|
-
} catch (error) {
|
|
554
|
-
koa.status = 502
|
|
555
|
-
koa.body = { error: 'send forward failed' }
|
|
556
|
-
}
|
|
557
|
-
})
|
|
558
|
-
|
|
559
|
-
ctx.server.get(`${config.basePath}/api/cache`, async (koa) => {
|
|
560
|
-
const platform = String(koa.query.platform ?? '')
|
|
561
|
-
let selfId = String(koa.query.selfId ?? '')
|
|
562
|
-
if (!selfId) {
|
|
563
|
-
selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
|
|
564
|
-
}
|
|
565
|
-
const type = String(koa.query.type ?? '')
|
|
566
|
-
if (!platform || !selfId || !type) {
|
|
567
|
-
koa.status = 400
|
|
568
|
-
koa.body = { error: 'missing cache params' }
|
|
569
|
-
return
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// 单条身份缓存:前端收消息时优先走这里,命中后不再请求 Satori API
|
|
573
|
-
const id = String(koa.query.userId ?? koa.query.groupId ?? koa.query.id ?? '')
|
|
574
|
-
if (id) {
|
|
575
|
-
if (type === 'user' || type === 'friend') {
|
|
576
|
-
const channelId = String(koa.query.channelId ?? '')
|
|
577
|
-
const contact = await contactCache.getUser(
|
|
578
|
-
platform,
|
|
579
|
-
selfId,
|
|
580
|
-
id,
|
|
581
|
-
String(koa.query.guildId ?? ''),
|
|
582
|
-
channelId,
|
|
583
|
-
String(koa.query.name ?? ''),
|
|
584
|
-
String(koa.query.avatar ?? ''),
|
|
585
|
-
)
|
|
586
|
-
koa.body = contact ?? null
|
|
587
|
-
return
|
|
588
|
-
}
|
|
589
|
-
if (type === 'group') {
|
|
590
|
-
const guildId = String(koa.query.guildId ?? '')
|
|
591
|
-
const channelId = String(koa.query.channelId ?? '')
|
|
592
|
-
const channelType = koa.query.channelType
|
|
593
|
-
const contact = await contactCache.getGroup(
|
|
594
|
-
platform,
|
|
595
|
-
selfId,
|
|
596
|
-
id,
|
|
597
|
-
guildId,
|
|
598
|
-
channelId,
|
|
599
|
-
String(koa.query.name ?? ''),
|
|
600
|
-
String(koa.query.avatar ?? ''),
|
|
601
|
-
channelType,
|
|
602
|
-
)
|
|
603
|
-
koa.body = contact ?? null
|
|
604
|
-
return
|
|
605
|
-
}
|
|
606
|
-
if (type === 'member') {
|
|
607
|
-
const groupId = String(koa.query.groupId ?? koa.query.id ?? '')
|
|
608
|
-
if (!groupId) {
|
|
609
|
-
koa.status = 400
|
|
610
|
-
koa.body = { error: 'missing groupId' }
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
const memberId = String(koa.query.userId ?? '')
|
|
614
|
-
if (memberId) {
|
|
615
|
-
koa.body = await database.getGroupMember(platform, selfId, groupId, memberId) ?? null
|
|
616
|
-
return
|
|
617
|
-
}
|
|
618
|
-
koa.body = {
|
|
619
|
-
members: await database.getGroupMembers(platform, selfId, groupId),
|
|
620
|
-
}
|
|
621
|
-
return
|
|
622
|
-
}
|
|
623
|
-
koa.status = 400
|
|
624
|
-
koa.body = { error: 'unsupported cache type' }
|
|
625
|
-
return
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
koa.body = {
|
|
629
|
-
contacts: await database.getContacts(platform, selfId, cacheType(type)),
|
|
630
|
-
}
|
|
631
|
-
})
|
|
632
|
-
|
|
633
|
-
ctx.server.post(`${config.basePath}/api/cache`, async (koa) => {
|
|
634
|
-
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
635
|
-
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
636
|
-
const platform = String(body.platform ?? '')
|
|
637
|
-
let selfId = String(body.selfId ?? '')
|
|
638
|
-
if (!selfId) {
|
|
639
|
-
selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
|
|
640
|
-
}
|
|
641
|
-
const type = String(body.type ?? '')
|
|
642
|
-
if (!platform || !selfId || !type) {
|
|
643
|
-
koa.status = 400
|
|
644
|
-
koa.body = { error: 'missing cache params' }
|
|
645
|
-
return
|
|
646
|
-
}
|
|
647
|
-
const normalizedType = cacheType(type)
|
|
648
|
-
if (type === 'member' && body.groupId) {
|
|
649
|
-
const groupId = String(body.groupId)
|
|
650
|
-
const contacts = Array.isArray(body.contacts) ? body.contacts as ContactCacheItem[] : []
|
|
651
|
-
if (body.append === true) {
|
|
652
|
-
for (const contact of contacts) {
|
|
653
|
-
await database.appendGroupMember(platform, selfId, groupId, contact)
|
|
654
|
-
}
|
|
655
|
-
} else {
|
|
656
|
-
await database.setGroupMembers(platform, selfId, groupId, contacts)
|
|
657
|
-
}
|
|
658
|
-
koa.body = {
|
|
659
|
-
members: await database.getGroupMembers(platform, selfId, groupId),
|
|
660
|
-
}
|
|
661
|
-
return
|
|
662
|
-
}
|
|
663
|
-
if (Array.isArray(body.contacts)) {
|
|
664
|
-
const contacts = body.contacts as ContactCacheItem[]
|
|
665
|
-
if (body.append === true) {
|
|
666
|
-
for (const contact of contacts) {
|
|
667
|
-
await database.appendContact(platform, selfId, normalizedType, contact)
|
|
668
|
-
}
|
|
669
|
-
} else {
|
|
670
|
-
await database.setContacts(platform, selfId, normalizedType, contacts)
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
koa.body = {
|
|
674
|
-
contacts: await database.getContacts(platform, selfId, normalizedType),
|
|
675
|
-
}
|
|
676
|
-
})
|
|
677
|
-
|
|
678
|
-
const serveFile = async (koa: WebContext, fileName: string) => {
|
|
679
|
-
const fullPath = path.join(webRoot, fileName)
|
|
680
|
-
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
|
|
681
|
-
await send(koa, path.relative(webRoot, fullPath), { root: webRoot })
|
|
682
|
-
return
|
|
683
|
-
}
|
|
684
|
-
koa.status = 200
|
|
685
|
-
koa.body = ''
|
|
686
|
-
await send(koa, 'index.html', { root: webRoot })
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const serveDevWeb = async (koa: WebContext): Promise<boolean> => {
|
|
690
|
-
const vite = getVite()
|
|
691
|
-
if (!vite) return false
|
|
692
|
-
koa.status = 200
|
|
693
|
-
koa.type = 'html'
|
|
694
|
-
koa.body = await transformWebIndex(vite, koa.path)
|
|
695
|
-
return true
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
ctx.server.get(`${config.basePath}/web(/.*)?`, async (koa) => {
|
|
699
|
-
if (await serveDevWeb(koa)) return
|
|
700
|
-
if (!existsSync(webRoot)) {
|
|
701
|
-
koa.status = 404
|
|
702
|
-
koa.body = ''
|
|
703
|
-
return
|
|
704
|
-
}
|
|
705
|
-
const fileName = koa.params?.[0]?.replace(/^\/+/, '') || 'index.html'
|
|
706
|
-
await serveFile(koa, fileName)
|
|
707
|
-
})
|
|
708
|
-
|
|
709
|
-
if (!existsSync(webRoot) && process.env.NODE_ENV !== 'development') {
|
|
710
|
-
logger.warn('未找到 client/web/dist,发布前请先执行 client/web 目录下的 npm run build')
|
|
711
|
-
}
|
|
712
|
-
}
|
|
1
|
+
import { Context } from 'koishi'
|
|
2
|
+
import {} from '@koishijs/plugin-server'
|
|
3
|
+
import { createReadStream, existsSync, promises as fs, statSync } from 'node:fs'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
import send from 'koa-send'
|
|
8
|
+
import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
|
|
9
|
+
import FileType from 'file-type'
|
|
10
|
+
|
|
11
|
+
import { Config } from './config'
|
|
12
|
+
import { ContactCacheService } from './cache'
|
|
13
|
+
import { ChatDatabase } from './database'
|
|
14
|
+
import { PluginLogger } from './logger'
|
|
15
|
+
import { ContactCacheItem, SelfMessagePayload, SelfMessageRecord } from './types'
|
|
16
|
+
|
|
17
|
+
interface ViteConsoleServer {
|
|
18
|
+
config?: {
|
|
19
|
+
server?: {
|
|
20
|
+
preTransformRequests?: boolean
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
transformIndexHtml(url: string, html: string, originalUrl?: string): Promise<string>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ViteConsoleLike {
|
|
27
|
+
vite?: ViteConsoleServer
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerWeb(
|
|
31
|
+
ctx: Context,
|
|
32
|
+
config: Config,
|
|
33
|
+
database: ChatDatabase,
|
|
34
|
+
contactCache: ContactCacheService,
|
|
35
|
+
logger: PluginLogger,
|
|
36
|
+
) {
|
|
37
|
+
const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
|
|
38
|
+
const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
|
|
39
|
+
const webPublic = path.resolve(__dirname, '..', 'client', 'web', 'public')
|
|
40
|
+
const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
|
|
41
|
+
const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
|
|
42
|
+
|
|
43
|
+
type WebContext = ParameterizedContext<DefaultState, DefaultContext>
|
|
44
|
+
const cacheType = (type: string) => type === 'user' ? 'friend' : type
|
|
45
|
+
|
|
46
|
+
const getVite = (): ViteConsoleServer | undefined => {
|
|
47
|
+
return (ctx.console as unknown as ViteConsoleLike).vite
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const viteFsUrl = (target: string): string => {
|
|
51
|
+
return `/vite/@fs/${path.resolve(target).split(path.sep).join('/')}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const transformWebIndex = async (vite: ViteConsoleServer, requestPath: string) => {
|
|
55
|
+
// 源码由 iframe 按需加载,避免 Koishi Vite 启动时提前转换整个 web 应用
|
|
56
|
+
if (vite.config?.server) vite.config.server.preTransformRequests = false
|
|
57
|
+
const mainFsUrl = `/@fs/${path.resolve(webSource, 'main.ts').split(path.sep).join('/')}`
|
|
58
|
+
const html = (await fs.readFile(webIndex, 'utf8')).replace(
|
|
59
|
+
'src="/src/main.ts"',
|
|
60
|
+
`src="${mainFsUrl}"`,
|
|
61
|
+
)
|
|
62
|
+
let transformed = await vite.transformIndexHtml('/chat-patch/web/index.html', html, requestPath)
|
|
63
|
+
transformed = transformed
|
|
64
|
+
.replace(/\/vite\/bcui/g, viteFsUrl(path.join(webPublic, 'bcui')))
|
|
65
|
+
.replace(/\/vite\/css/g, viteFsUrl(path.join(webPublic, 'css')))
|
|
66
|
+
return transformed
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const servePublicFile = async (koa: WebContext, prefix: string) => {
|
|
70
|
+
const relative = (koa.params?.[0] ?? '').replace(/^\/+/, '')
|
|
71
|
+
const fullPath = path.resolve(webPublic, prefix, relative)
|
|
72
|
+
if (!fullPath.startsWith(webPublic + path.sep)) {
|
|
73
|
+
koa.status = 403
|
|
74
|
+
koa.body = ''
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
|
|
78
|
+
await send(koa, path.relative(webPublic, fullPath), { root: webPublic })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
koa.status = 404
|
|
82
|
+
koa.body = ''
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// web 应用里仍有 /img 这类运行时绝对路径,开发模式直接由 Koishi 服务托管
|
|
86
|
+
const registerDevPublic = (pattern: string, prefix: string) => {
|
|
87
|
+
ctx.server.get(pattern, async (koa: WebContext, next: () => Promise<void>) => {
|
|
88
|
+
if (!getVite()) return next()
|
|
89
|
+
await servePublicFile(koa, prefix)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
registerDevPublic('/img(.*)', 'img')
|
|
94
|
+
|
|
95
|
+
const toExtension = (value: string): string => {
|
|
96
|
+
const ext = value.startsWith('.') ? value : `.${value}`
|
|
97
|
+
return ext.toLowerCase()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const normalizeGroupId = (value: string): string => {
|
|
101
|
+
const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
|
|
102
|
+
const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
|
|
103
|
+
return wrapped ? wrapped[1] : raw || value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const historyChannelCandidates = (channelId: string): string[] => {
|
|
107
|
+
const raw = normalizeGroupId(channelId)
|
|
108
|
+
const candidates = [channelId]
|
|
109
|
+
if (raw && raw !== channelId) candidates.push(raw)
|
|
110
|
+
const id = raw || channelId
|
|
111
|
+
if (!/^(?:group|room|chat|channel|guild|private):/i.test(channelId)) {
|
|
112
|
+
candidates.push(`group:${id}`, `private:${id}`)
|
|
113
|
+
}
|
|
114
|
+
if (id) {
|
|
115
|
+
candidates.push(` [_${id}_] `, `_${id}_`, `[${id}]`, `group:${id}`)
|
|
116
|
+
}
|
|
117
|
+
return [...new Set(candidates)]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const mimeToExt: Record<string, string> = {
|
|
121
|
+
'audio/mpeg': '.mp3',
|
|
122
|
+
'audio/mp3': '.mp3',
|
|
123
|
+
'audio/mp4': '.m4a',
|
|
124
|
+
'audio/x-m4a': '.m4a',
|
|
125
|
+
'audio/wav': '.wav',
|
|
126
|
+
'audio/x-wav': '.wav',
|
|
127
|
+
'audio/webm': '.webm',
|
|
128
|
+
'audio/ogg': '.ogg',
|
|
129
|
+
'audio/aac': '.aac',
|
|
130
|
+
'audio/flac': '.flac',
|
|
131
|
+
'image/jpeg': '.jpg',
|
|
132
|
+
'image/png': '.png',
|
|
133
|
+
'image/gif': '.gif',
|
|
134
|
+
'image/webp': '.webp',
|
|
135
|
+
'video/mp4': '.mp4',
|
|
136
|
+
'video/webm': '.webm',
|
|
137
|
+
'video/quicktime': '.mov',
|
|
138
|
+
'video/x-msvideo': '.avi',
|
|
139
|
+
'video/x-matroska': '.mkv',
|
|
140
|
+
'video/x-flv': '.flv',
|
|
141
|
+
'video/3gpp': '.3gp',
|
|
142
|
+
'video/ogg': '.ogv',
|
|
143
|
+
'video/mp2t': '.ts',
|
|
144
|
+
}
|
|
145
|
+
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
|
|
146
|
+
|
|
147
|
+
ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
|
|
148
|
+
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
149
|
+
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
150
|
+
let source = String(body.dataUrl ?? body.data ?? '')
|
|
151
|
+
let name = String(body.name ?? '')
|
|
152
|
+
let mime = ''
|
|
153
|
+
let buffer: Buffer | null = null
|
|
154
|
+
if (source) {
|
|
155
|
+
if (source.startsWith('base64://')) source = source.slice(9)
|
|
156
|
+
const comma = source.indexOf('base64,')
|
|
157
|
+
const base64 = comma >= 0 ? source.slice(comma + 7) : source
|
|
158
|
+
const normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
|
|
159
|
+
try {
|
|
160
|
+
buffer = Buffer.from(normalizedBase64, 'base64')
|
|
161
|
+
} catch {
|
|
162
|
+
koa.status = 400
|
|
163
|
+
koa.body = { error: 'invalid media data' }
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
mime = source.startsWith('data:')
|
|
167
|
+
? source.slice(5, source.indexOf(';')).toLowerCase()
|
|
168
|
+
: ''
|
|
169
|
+
} else {
|
|
170
|
+
const chunks: Buffer[] = []
|
|
171
|
+
for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
|
|
172
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
173
|
+
}
|
|
174
|
+
buffer = Buffer.concat(chunks)
|
|
175
|
+
name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
|
|
176
|
+
try {
|
|
177
|
+
name = decodeURIComponent(name)
|
|
178
|
+
} catch {
|
|
179
|
+
// 保持原始名称即可
|
|
180
|
+
}
|
|
181
|
+
mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
|
|
182
|
+
}
|
|
183
|
+
if (!buffer || !buffer.length) {
|
|
184
|
+
koa.status = 400
|
|
185
|
+
koa.body = { error: 'missing media data' }
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (buffer.length > MAX_UPLOAD_BYTES) {
|
|
189
|
+
koa.status = 413
|
|
190
|
+
koa.body = { error: 'media file too large' }
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
if (!buffer.length) {
|
|
194
|
+
koa.status = 400
|
|
195
|
+
koa.body = { error: 'invalid media data' }
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await fs.mkdir(uploadDir, { recursive: true })
|
|
200
|
+
let detected: { ext?: string } | undefined
|
|
201
|
+
try {
|
|
202
|
+
const result = await FileType.fromBuffer(buffer)
|
|
203
|
+
detected = result ?? undefined
|
|
204
|
+
} catch {
|
|
205
|
+
// 部分音频/文件无法识别时继续用 MIME 或文件名兜底
|
|
206
|
+
detected = undefined
|
|
207
|
+
}
|
|
208
|
+
const mimeExt = mimeToExt[mime] || ''
|
|
209
|
+
const nameExt = path.extname(name).toLowerCase()
|
|
210
|
+
const ext = detected?.ext
|
|
211
|
+
? toExtension(detected.ext)
|
|
212
|
+
: mimeExt || nameExt || '.bin'
|
|
213
|
+
const filename = `${createHash('md5').update(buffer).digest('hex')}${ext}`
|
|
214
|
+
const filePath = path.join(uploadDir, filename)
|
|
215
|
+
if (!existsSync(filePath)) {
|
|
216
|
+
await fs.writeFile(filePath, buffer)
|
|
217
|
+
}
|
|
218
|
+
koa.body = {
|
|
219
|
+
path: pathToFileURL(filePath).href,
|
|
220
|
+
localPath: filePath,
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
|
|
225
|
+
const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
|
|
226
|
+
if (!fileName || fileName === '.' || fileName === '..') {
|
|
227
|
+
koa.status = 400
|
|
228
|
+
koa.body = { error: 'missing media file' }
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const filePath = path.resolve(uploadDir, fileName)
|
|
232
|
+
const root = path.resolve(uploadDir)
|
|
233
|
+
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
|
234
|
+
koa.status = 400
|
|
235
|
+
koa.body = { error: 'invalid media file' }
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
239
|
+
koa.status = 404
|
|
240
|
+
koa.body = { error: 'media file not found' }
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const size = statSync(filePath).size
|
|
244
|
+
koa.set('Accept-Ranges', 'bytes')
|
|
245
|
+
if (fileName.toLowerCase().endsWith('.webm')) {
|
|
246
|
+
koa.type = 'audio/webm'
|
|
247
|
+
} else {
|
|
248
|
+
koa.type = fileName
|
|
249
|
+
}
|
|
250
|
+
const range = koa.headers.range
|
|
251
|
+
if (range) {
|
|
252
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
|
|
253
|
+
if (!match) {
|
|
254
|
+
koa.status = 416
|
|
255
|
+
koa.set('Content-Range', `bytes */${size}`)
|
|
256
|
+
koa.body = ''
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
let start = 0
|
|
260
|
+
let end = size - 1
|
|
261
|
+
if (match[1] === '' && match[2]) {
|
|
262
|
+
start = Math.max(0, size - Number(match[2]))
|
|
263
|
+
} else if (match[1]) {
|
|
264
|
+
start = Number(match[1])
|
|
265
|
+
end = match[2] ? Number(match[2]) : end
|
|
266
|
+
}
|
|
267
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
|
|
268
|
+
koa.status = 416
|
|
269
|
+
koa.set('Content-Range', `bytes */${size}`)
|
|
270
|
+
koa.body = ''
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (end >= size) end = size - 1
|
|
274
|
+
if (end < start) end = start
|
|
275
|
+
koa.status = 206
|
|
276
|
+
koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
277
|
+
koa.set('Content-Length', String(end - start + 1))
|
|
278
|
+
koa.body = createReadStream(filePath, { start, end })
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
koa.set('Content-Length', String(size))
|
|
282
|
+
koa.body = createReadStream(filePath)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
|
|
286
|
+
const entries = await database.getAllContacts()
|
|
287
|
+
const botMap = new Map<string, {
|
|
288
|
+
platform: string
|
|
289
|
+
selfId: string
|
|
290
|
+
groups: ContactCacheItem[]
|
|
291
|
+
friends: ContactCacheItem[]
|
|
292
|
+
}>()
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
const key = JSON.stringify([entry.platform, entry.selfId])
|
|
295
|
+
const bot = botMap.get(key) ?? {
|
|
296
|
+
platform: entry.platform,
|
|
297
|
+
selfId: entry.selfId,
|
|
298
|
+
groups: [],
|
|
299
|
+
friends: [],
|
|
300
|
+
}
|
|
301
|
+
if (entry.type === 'group') bot.groups = entry.contacts
|
|
302
|
+
if (entry.type === 'friend') bot.friends = entry.contacts
|
|
303
|
+
botMap.set(key, bot)
|
|
304
|
+
}
|
|
305
|
+
koa.body = { bots: [...botMap.values()] }
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
ctx.server.get(`${config.basePath}/api/history`, async (koa) => {
|
|
309
|
+
const platform = String(koa.query.platform ?? '')
|
|
310
|
+
const selfId = String(koa.query.selfId ?? '')
|
|
311
|
+
const channelId = String(koa.query.channelId ?? '')
|
|
312
|
+
if (!platform || !selfId || !channelId) {
|
|
313
|
+
koa.status = 400
|
|
314
|
+
koa.body = { error: 'missing history params' }
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel)
|
|
318
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel
|
|
319
|
+
const beforeTime = Number(koa.query.beforeTime ?? 0)
|
|
320
|
+
const queryMessages = async (cid: string) => {
|
|
321
|
+
return Number.isFinite(beforeTime) && beforeTime > 0
|
|
322
|
+
? database.listMessagesBefore(platform, selfId, cid, beforeTime, limit)
|
|
323
|
+
: database.listMessages(platform, selfId, cid, limit)
|
|
324
|
+
}
|
|
325
|
+
let messages: Awaited<ReturnType<typeof queryMessages>> = []
|
|
326
|
+
for (const candidate of historyChannelCandidates(channelId)) {
|
|
327
|
+
messages = await queryMessages(candidate)
|
|
328
|
+
if (messages.length) break
|
|
329
|
+
}
|
|
330
|
+
let selfMessages: SelfMessageRecord[] = []
|
|
331
|
+
const querySelfMessages = async (cid: string): Promise<SelfMessageRecord[]> => {
|
|
332
|
+
return Number.isFinite(beforeTime) && beforeTime > 0
|
|
333
|
+
? database.listSelfMessagesBefore(platform, selfId, cid, beforeTime, limit)
|
|
334
|
+
: database.listSelfMessages(platform, selfId, cid, limit)
|
|
335
|
+
}
|
|
336
|
+
for (const candidate of historyChannelCandidates(channelId)) {
|
|
337
|
+
selfMessages = await querySelfMessages(candidate)
|
|
338
|
+
if (selfMessages.length) break
|
|
339
|
+
}
|
|
340
|
+
// 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
|
|
341
|
+
messages.sort((a, b) => {
|
|
342
|
+
const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
|
|
343
|
+
const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
|
|
344
|
+
return timeA - timeB
|
|
345
|
+
})
|
|
346
|
+
selfMessages.sort((a, b) => a.sentAt - b.sentAt)
|
|
347
|
+
koa.body = { messages, selfMessages }
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
|
|
351
|
+
const platform = String(koa.query.platform ?? '')
|
|
352
|
+
const selfId = String(koa.query.selfId ?? '')
|
|
353
|
+
const channelId = String(koa.query.channelId ?? '')
|
|
354
|
+
if (!platform || !selfId || !channelId) {
|
|
355
|
+
koa.status = 400
|
|
356
|
+
koa.body = { error: 'missing self-message params' }
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
const parsedLimit = Number(koa.query.limit ?? config.historyPageSize)
|
|
360
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.historyPageSize
|
|
361
|
+
const beforeTime = Number(koa.query.beforeTime ?? 0)
|
|
362
|
+
let messages: SelfMessageRecord[] = []
|
|
363
|
+
for (const candidate of historyChannelCandidates(channelId)) {
|
|
364
|
+
messages = Number.isFinite(beforeTime) && beforeTime > 0
|
|
365
|
+
? await database.listSelfMessagesBefore(platform, selfId, candidate, beforeTime, limit)
|
|
366
|
+
: await database.listSelfMessages(platform, selfId, candidate, limit)
|
|
367
|
+
if (messages.length) break
|
|
368
|
+
}
|
|
369
|
+
messages.sort((a, b) => a.sentAt - b.sentAt)
|
|
370
|
+
koa.body = { messages }
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
ctx.server.post(`${config.basePath}/api/self-messages`, async (koa) => {
|
|
374
|
+
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
375
|
+
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
376
|
+
const payload: SelfMessagePayload = {
|
|
377
|
+
id: typeof body.id === 'string' ? body.id : undefined,
|
|
378
|
+
platform: String(body.platform ?? ''),
|
|
379
|
+
selfId: String(body.selfId ?? ''),
|
|
380
|
+
channelId: String(body.channelId ?? ''),
|
|
381
|
+
guildId: typeof body.guildId === 'string' ? body.guildId : undefined,
|
|
382
|
+
channelType: body.channelType === 'user' ? 'user' : 'group',
|
|
383
|
+
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
384
|
+
content: typeof body.content === 'string' ? body.content : undefined,
|
|
385
|
+
elements: Array.isArray(body.elements) ? body.elements as unknown[] : undefined,
|
|
386
|
+
message: Array.isArray(body.message) ? body.message as unknown[] : undefined,
|
|
387
|
+
forwardId: typeof body.forwardId === 'string' ? body.forwardId : undefined,
|
|
388
|
+
forwardContent: Array.isArray(body.forwardContent) ? body.forwardContent as unknown[] : undefined,
|
|
389
|
+
sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
|
|
390
|
+
? body.sentAt
|
|
391
|
+
: Date.now(),
|
|
392
|
+
sequence: typeof body.sequence === 'number' && Number.isFinite(body.sequence)
|
|
393
|
+
? body.sequence
|
|
394
|
+
: 0,
|
|
395
|
+
source: body.source === 'plugin' ? 'plugin' : 'webui',
|
|
396
|
+
kind: typeof body.kind === 'string' ? body.kind : 'text',
|
|
397
|
+
}
|
|
398
|
+
if (!payload.platform || !payload.selfId || !payload.channelId) {
|
|
399
|
+
koa.status = 400
|
|
400
|
+
koa.body = { error: 'missing self-message params' }
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
const record: SelfMessageRecord = {
|
|
404
|
+
id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
405
|
+
platform: payload.platform,
|
|
406
|
+
selfId: payload.selfId,
|
|
407
|
+
channelId: payload.channelId,
|
|
408
|
+
guildId: payload.guildId,
|
|
409
|
+
channelType: payload.channelType,
|
|
410
|
+
messageId: payload.messageId,
|
|
411
|
+
content: payload.content,
|
|
412
|
+
elements: payload.elements,
|
|
413
|
+
message: payload.message,
|
|
414
|
+
forwardId: payload.forwardId,
|
|
415
|
+
forwardContent: payload.forwardContent,
|
|
416
|
+
sentAt: payload.sentAt ?? Date.now(),
|
|
417
|
+
sequence: payload.sequence ?? 0,
|
|
418
|
+
source: payload.source ?? 'webui',
|
|
419
|
+
kind: payload.kind ?? 'text',
|
|
420
|
+
}
|
|
421
|
+
await database.upsertSelfMessage(record)
|
|
422
|
+
koa.body = { ok: true, message: record }
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
ctx.server.get(`${config.basePath}/api/forward`, async (koa) => {
|
|
426
|
+
const platform = String(koa.query.platform ?? '')
|
|
427
|
+
const selfId = String(koa.query.selfId ?? '')
|
|
428
|
+
const channelId = String(koa.query.channelId ?? '')
|
|
429
|
+
const id = String(koa.query.id ?? '')
|
|
430
|
+
if (!platform || !selfId || !id) {
|
|
431
|
+
koa.status = 400
|
|
432
|
+
koa.body = { error: 'missing forward params' }
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (channelId) {
|
|
436
|
+
const cached = await database.findSelfForwardContent(platform, selfId, channelId, id)
|
|
437
|
+
if (cached) {
|
|
438
|
+
koa.body = cached
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (platform !== 'onebot') {
|
|
443
|
+
koa.status = 501
|
|
444
|
+
koa.body = { error: 'forward api not supported' }
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
const bot = ctx.bots.find((item) => {
|
|
448
|
+
return item.platform === platform && item.selfId === selfId
|
|
449
|
+
})
|
|
450
|
+
if (!bot) {
|
|
451
|
+
koa.status = 404
|
|
452
|
+
koa.body = { error: 'bot not found' }
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const internal = (bot as unknown as {
|
|
456
|
+
internal?: { getForwardMsg?: (messageId: string) => Promise<unknown> }
|
|
457
|
+
}).internal
|
|
458
|
+
if (!internal?.getForwardMsg) {
|
|
459
|
+
koa.status = 501
|
|
460
|
+
koa.body = { error: 'forward api not supported' }
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
koa.body = await internal.getForwardMsg(id)
|
|
465
|
+
} catch (error) {
|
|
466
|
+
koa.status = 502
|
|
467
|
+
koa.body = { error: 'forward api failed' }
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
ctx.server.post(`${config.basePath}/api/send-forward`, async (koa) => {
|
|
472
|
+
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
473
|
+
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
474
|
+
const platform = String(body.platform ?? '')
|
|
475
|
+
const selfId = String(body.selfId ?? '')
|
|
476
|
+
const type = String(body.type ?? '')
|
|
477
|
+
const id = String(body.id ?? '')
|
|
478
|
+
if (!platform || !selfId || !type || !id) {
|
|
479
|
+
koa.status = 400
|
|
480
|
+
koa.body = { error: 'missing send-forward params' }
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (platform !== 'onebot') {
|
|
484
|
+
koa.status = 501
|
|
485
|
+
koa.body = { error: 'forward api not supported' }
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
const bot = ctx.bots.find((item) => {
|
|
489
|
+
return item.platform === platform && item.selfId === selfId
|
|
490
|
+
})
|
|
491
|
+
if (!bot) {
|
|
492
|
+
koa.status = 404
|
|
493
|
+
koa.body = { error: 'bot not found' }
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
const internal = (bot as unknown as {
|
|
497
|
+
internal?: {
|
|
498
|
+
sendGroupForwardMsg?: (groupId: string, messages: unknown[]) => Promise<unknown>
|
|
499
|
+
sendPrivateForwardMsg?: (userId: string, messages: unknown[]) => Promise<unknown>
|
|
500
|
+
}
|
|
501
|
+
}).internal
|
|
502
|
+
if (type === 'group' && !internal?.sendGroupForwardMsg) {
|
|
503
|
+
koa.status = 501
|
|
504
|
+
koa.body = { error: 'forward api not supported' }
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
if (type !== 'group' && !internal?.sendPrivateForwardMsg) {
|
|
508
|
+
koa.status = 501
|
|
509
|
+
koa.body = { error: 'forward api not supported' }
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
const messages = Array.isArray(body.messages) ? body.messages as unknown[] : []
|
|
513
|
+
const nodes = messages.map((raw) => {
|
|
514
|
+
const item = typeof raw === 'object' && raw !== null
|
|
515
|
+
? raw as Record<string, unknown>
|
|
516
|
+
: {}
|
|
517
|
+
const nodeData = typeof item.data === 'object' && item.data !== null
|
|
518
|
+
? item.data as Record<string, unknown>
|
|
519
|
+
: item
|
|
520
|
+
const content = Array.isArray(item.content)
|
|
521
|
+
? item.content as unknown[]
|
|
522
|
+
: Array.isArray(nodeData.content)
|
|
523
|
+
? nodeData.content as unknown[]
|
|
524
|
+
: []
|
|
525
|
+
const userId = String(item.user_id ?? nodeData.user_id ?? nodeData.uin ?? '')
|
|
526
|
+
const nickname = String(item.nickname ?? nodeData.nickname ?? nodeData.name ?? '')
|
|
527
|
+
return {
|
|
528
|
+
type: 'node',
|
|
529
|
+
data: {
|
|
530
|
+
user_id: userId,
|
|
531
|
+
nickname,
|
|
532
|
+
// koishi 的 OneBot adapter 发送自定义转发节点时使用 uin/name
|
|
533
|
+
uin: userId,
|
|
534
|
+
name: nickname,
|
|
535
|
+
time: String(item.time ?? nodeData.time ?? Math.floor(Date.now() / 1000)),
|
|
536
|
+
content: content.map((segmentRaw) => {
|
|
537
|
+
const segment = typeof segmentRaw === 'object' && segmentRaw !== null
|
|
538
|
+
? segmentRaw as Record<string, unknown>
|
|
539
|
+
: {}
|
|
540
|
+
const copy = { ...segment }
|
|
541
|
+
const segmentType = String(copy.type ?? 'text')
|
|
542
|
+
delete copy.type
|
|
543
|
+
return { type: segmentType, data: copy }
|
|
544
|
+
}),
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
try {
|
|
549
|
+
// 必须从 internal 上直接调用,避免方法被取出后丢失 this
|
|
550
|
+
koa.body = type === 'group'
|
|
551
|
+
? await internal?.sendGroupForwardMsg?.(id, nodes)
|
|
552
|
+
: await internal?.sendPrivateForwardMsg?.(id, nodes)
|
|
553
|
+
} catch (error) {
|
|
554
|
+
koa.status = 502
|
|
555
|
+
koa.body = { error: 'send forward failed' }
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
ctx.server.get(`${config.basePath}/api/cache`, async (koa) => {
|
|
560
|
+
const platform = String(koa.query.platform ?? '')
|
|
561
|
+
let selfId = String(koa.query.selfId ?? '')
|
|
562
|
+
if (!selfId) {
|
|
563
|
+
selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
|
|
564
|
+
}
|
|
565
|
+
const type = String(koa.query.type ?? '')
|
|
566
|
+
if (!platform || !selfId || !type) {
|
|
567
|
+
koa.status = 400
|
|
568
|
+
koa.body = { error: 'missing cache params' }
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 单条身份缓存:前端收消息时优先走这里,命中后不再请求 Satori API
|
|
573
|
+
const id = String(koa.query.userId ?? koa.query.groupId ?? koa.query.id ?? '')
|
|
574
|
+
if (id) {
|
|
575
|
+
if (type === 'user' || type === 'friend') {
|
|
576
|
+
const channelId = String(koa.query.channelId ?? '')
|
|
577
|
+
const contact = await contactCache.getUser(
|
|
578
|
+
platform,
|
|
579
|
+
selfId,
|
|
580
|
+
id,
|
|
581
|
+
String(koa.query.guildId ?? ''),
|
|
582
|
+
channelId,
|
|
583
|
+
String(koa.query.name ?? ''),
|
|
584
|
+
String(koa.query.avatar ?? ''),
|
|
585
|
+
)
|
|
586
|
+
koa.body = contact ?? null
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
if (type === 'group') {
|
|
590
|
+
const guildId = String(koa.query.guildId ?? '')
|
|
591
|
+
const channelId = String(koa.query.channelId ?? '')
|
|
592
|
+
const channelType = koa.query.channelType
|
|
593
|
+
const contact = await contactCache.getGroup(
|
|
594
|
+
platform,
|
|
595
|
+
selfId,
|
|
596
|
+
id,
|
|
597
|
+
guildId,
|
|
598
|
+
channelId,
|
|
599
|
+
String(koa.query.name ?? ''),
|
|
600
|
+
String(koa.query.avatar ?? ''),
|
|
601
|
+
channelType,
|
|
602
|
+
)
|
|
603
|
+
koa.body = contact ?? null
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
if (type === 'member') {
|
|
607
|
+
const groupId = String(koa.query.groupId ?? koa.query.id ?? '')
|
|
608
|
+
if (!groupId) {
|
|
609
|
+
koa.status = 400
|
|
610
|
+
koa.body = { error: 'missing groupId' }
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
const memberId = String(koa.query.userId ?? '')
|
|
614
|
+
if (memberId) {
|
|
615
|
+
koa.body = await database.getGroupMember(platform, selfId, groupId, memberId) ?? null
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
koa.body = {
|
|
619
|
+
members: await database.getGroupMembers(platform, selfId, groupId),
|
|
620
|
+
}
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
koa.status = 400
|
|
624
|
+
koa.body = { error: 'unsupported cache type' }
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
koa.body = {
|
|
629
|
+
contacts: await database.getContacts(platform, selfId, cacheType(type)),
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
ctx.server.post(`${config.basePath}/api/cache`, async (koa) => {
|
|
634
|
+
const requestBody = (koa.request as unknown as { body?: unknown }).body
|
|
635
|
+
const body = (requestBody ?? {}) as Record<string, unknown>
|
|
636
|
+
const platform = String(body.platform ?? '')
|
|
637
|
+
let selfId = String(body.selfId ?? '')
|
|
638
|
+
if (!selfId) {
|
|
639
|
+
selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? ''
|
|
640
|
+
}
|
|
641
|
+
const type = String(body.type ?? '')
|
|
642
|
+
if (!platform || !selfId || !type) {
|
|
643
|
+
koa.status = 400
|
|
644
|
+
koa.body = { error: 'missing cache params' }
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
const normalizedType = cacheType(type)
|
|
648
|
+
if (type === 'member' && body.groupId) {
|
|
649
|
+
const groupId = String(body.groupId)
|
|
650
|
+
const contacts = Array.isArray(body.contacts) ? body.contacts as ContactCacheItem[] : []
|
|
651
|
+
if (body.append === true) {
|
|
652
|
+
for (const contact of contacts) {
|
|
653
|
+
await database.appendGroupMember(platform, selfId, groupId, contact)
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
await database.setGroupMembers(platform, selfId, groupId, contacts)
|
|
657
|
+
}
|
|
658
|
+
koa.body = {
|
|
659
|
+
members: await database.getGroupMembers(platform, selfId, groupId),
|
|
660
|
+
}
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
if (Array.isArray(body.contacts)) {
|
|
664
|
+
const contacts = body.contacts as ContactCacheItem[]
|
|
665
|
+
if (body.append === true) {
|
|
666
|
+
for (const contact of contacts) {
|
|
667
|
+
await database.appendContact(platform, selfId, normalizedType, contact)
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
await database.setContacts(platform, selfId, normalizedType, contacts)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
koa.body = {
|
|
674
|
+
contacts: await database.getContacts(platform, selfId, normalizedType),
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
const serveFile = async (koa: WebContext, fileName: string) => {
|
|
679
|
+
const fullPath = path.join(webRoot, fileName)
|
|
680
|
+
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
|
|
681
|
+
await send(koa, path.relative(webRoot, fullPath), { root: webRoot })
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
koa.status = 200
|
|
685
|
+
koa.body = ''
|
|
686
|
+
await send(koa, 'index.html', { root: webRoot })
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const serveDevWeb = async (koa: WebContext): Promise<boolean> => {
|
|
690
|
+
const vite = getVite()
|
|
691
|
+
if (!vite) return false
|
|
692
|
+
koa.status = 200
|
|
693
|
+
koa.type = 'html'
|
|
694
|
+
koa.body = await transformWebIndex(vite, koa.path)
|
|
695
|
+
return true
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
ctx.server.get(`${config.basePath}/web(/.*)?`, async (koa) => {
|
|
699
|
+
if (await serveDevWeb(koa)) return
|
|
700
|
+
if (!existsSync(webRoot)) {
|
|
701
|
+
koa.status = 404
|
|
702
|
+
koa.body = ''
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
const fileName = koa.params?.[0]?.replace(/^\/+/, '') || 'index.html'
|
|
706
|
+
await serveFile(koa, fileName)
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
if (!existsSync(webRoot) && process.env.NODE_ENV !== 'development') {
|
|
710
|
+
logger.warn('未找到 client/web/dist,发布前请先执行 client/web 目录下的 npm run build')
|
|
711
|
+
}
|
|
712
|
+
}
|