koishi-plugin-chat-patch 5.6.0 → 6.0.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-BkRnAj_r.js +28 -0
- package/client/web/dist/assets/{Chat-BXVWj5Bd.css → Chat-D4tCqRrO.css} +1 -1
- package/client/web/dist/assets/{MsgBody-CzrxyiFO.js → MsgBody-BaoDyXUz.js} +1 -1
- package/client/web/dist/assets/{MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js → MsgBody.vue_vue_type_script_setup_true_lang-CgpEB8mj.js} +1 -1
- package/client/web/dist/assets/{index-EJ2CcODr.js → index--7-eCx0r.js} +57 -57
- package/client/web/dist/index.html +1 -1
- package/client/web/src/function/connect.ts +5 -12
- package/client/web/src/function/satori.ts +18 -12
- package/client/web/src/function/utils/appUtil.ts +27 -6
- package/client/web/src/pages/Chat.vue +32 -24
- package/lib/database.d.ts +5 -3
- package/lib/gateway.d.ts +2 -4
- package/lib/index.d.ts +17 -0
- package/lib/index.js +5359 -452
- package/lib/media.d.ts +1 -1
- package/lib/recorder.d.ts +1 -4
- package/lib/self-message.d.ts +1 -4
- package/package.json +25 -10
- package/src/bootstrap.ts +3 -2
- package/src/config.ts +1 -5
- package/src/database.ts +297 -131
- package/src/gateway.ts +21 -13
- package/src/index.ts +59 -54
- package/src/media.ts +43 -12
- package/src/recorder.ts +0 -11
- package/src/satori.ts +8 -5
- package/src/self-message.ts +3 -13
- package/src/server.d.ts +24 -9
- package/src/web.ts +144 -78
- package/client/web/dist/assets/Chat-m4-U5uHa.js +0 -28
- package/lib/bootstrap.d.ts +0 -6
- package/lib/satori.d.ts +0 -3
- package/lib/types.d.ts +0 -138
- package/lib/web.d.ts +0 -7
package/src/index.ts
CHANGED
|
@@ -1,60 +1,65 @@
|
|
|
1
|
-
import { Context } from 'koishi'
|
|
2
|
-
import { Console } from '@koishijs/console'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
|
|
5
|
-
import { Config } from './config'
|
|
6
|
-
import { createPluginLogger } from './logger'
|
|
7
|
-
import { ContactCacheService } from './cache'
|
|
8
|
-
import { ChatDatabase } from './database'
|
|
1
|
+
import { Context } from 'koishi'
|
|
2
|
+
import { Console } from '@koishijs/console'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { Config } from './config'
|
|
6
|
+
import { createPluginLogger } from './logger'
|
|
7
|
+
import { ContactCacheService } from './cache'
|
|
8
|
+
import { ChatDatabase } from './database'
|
|
9
9
|
import { Recorder } from './recorder'
|
|
10
10
|
import { SatoriGateway } from './gateway'
|
|
11
11
|
import { MediaManager } from './media'
|
|
12
12
|
import { SelfMessageRecorder } from './self-message'
|
|
13
13
|
import { registerBootstrap } from './bootstrap'
|
|
14
14
|
import { registerWeb } from './web'
|
|
15
|
-
|
|
16
|
-
export const name = 'chat-patch'
|
|
17
|
-
export const reusable = false
|
|
18
|
-
export const filter = false
|
|
15
|
+
|
|
16
|
+
export const name = 'chat-patch'
|
|
17
|
+
export const reusable = false
|
|
18
|
+
export const filter = false
|
|
19
19
|
export const inject = {
|
|
20
|
-
required: ['console', 'server', 'http', 'satori.server'],
|
|
20
|
+
required: ['console', 'server', 'http', 'satori.server', 'node'],
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
declare module 'koishi' {
|
|
24
|
-
interface Context {
|
|
25
|
-
console: Console
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const usage = `
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
基于 Satori 协议的 Koishi 后台聊天室。
|
|
33
|
-
需要在 Koishi 中启用 server-satori,并在插件内构建 web 应用。
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
22
|
+
|
|
23
|
+
declare module 'koishi' {
|
|
24
|
+
interface Context {
|
|
25
|
+
console: Console
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const usage = `
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
基于 Satori 协议的 Koishi 后台聊天室。
|
|
33
|
+
需要在 Koishi 中启用 server-satori,并在插件内构建 web 应用。
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
需要安装 w-node 插件,并开启 w-node、market、server-satori 插件。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
export { Config } from './config'
|
|
43
|
+
|
|
44
|
+
export async function apply(ctx: Context, config: Config) {
|
|
45
|
+
const pluginLogger = createPluginLogger(ctx.logger('chat-patch'), config)
|
|
46
|
+
|
|
47
|
+
const database = new ChatDatabase(ctx, config, pluginLogger)
|
|
48
|
+
await database.initialize()
|
|
49
|
+
await database.cleanupExcess()
|
|
50
|
+
const contactCache = new ContactCacheService(ctx, database, pluginLogger)
|
|
51
|
+
|
|
52
|
+
const media = new MediaManager(ctx, config, database, pluginLogger)
|
|
53
|
+
media.start()
|
|
54
|
+
|
|
55
|
+
const recorder = new Recorder(database, media, contactCache, pluginLogger)
|
|
56
|
+
const gateway = new SatoriGateway(ctx, database, recorder, pluginLogger, (payload) => {
|
|
52
57
|
void ctx.console.broadcast('chat-patch/event', payload).catch((error) => {
|
|
53
58
|
pluginLogger.warn('推送前端事件失败:', error)
|
|
54
59
|
})
|
|
55
60
|
})
|
|
56
61
|
|
|
57
|
-
const selfMessages = new SelfMessageRecorder(ctx,
|
|
62
|
+
const selfMessages = new SelfMessageRecorder(ctx, database, media, pluginLogger)
|
|
58
63
|
selfMessages.start()
|
|
59
64
|
|
|
60
65
|
void gateway.start().catch((error) => {
|
|
@@ -63,17 +68,17 @@ export async function apply(ctx: Context, config: Config) {
|
|
|
63
68
|
|
|
64
69
|
registerBootstrap(ctx, config, database, pluginLogger, () => gateway.getLogins())
|
|
65
70
|
registerWeb(ctx, config, database, contactCache, media, pluginLogger)
|
|
66
|
-
|
|
67
|
-
ctx.console.addEntry({
|
|
68
|
-
dev: path.resolve(__dirname, '../client/index.ts'),
|
|
69
|
-
prod: path.resolve(__dirname, '../dist'),
|
|
70
|
-
})
|
|
71
|
-
|
|
71
|
+
|
|
72
|
+
ctx.console.addEntry({
|
|
73
|
+
dev: path.resolve(__dirname, '../client/index.ts'),
|
|
74
|
+
prod: path.resolve(__dirname, '../dist'),
|
|
75
|
+
})
|
|
76
|
+
|
|
72
77
|
ctx.on('dispose', async () => {
|
|
73
78
|
gateway.dispose()
|
|
74
79
|
selfMessages.dispose()
|
|
75
80
|
media.dispose()
|
|
76
|
-
await database.dispose()
|
|
77
|
-
pluginLogger.logInfo('chat-patch 已卸载')
|
|
78
|
-
})
|
|
79
|
-
}
|
|
81
|
+
await database.dispose()
|
|
82
|
+
pluginLogger.logInfo('chat-patch 已卸载')
|
|
83
|
+
})
|
|
84
|
+
}
|
package/src/media.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Context } from 'koishi'
|
|
2
2
|
import {} from '@koishijs/plugin-server'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
|
-
import { createReadStream, promises as fs } from 'node:fs'
|
|
4
|
+
import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
+
import { Readable } from 'node:stream'
|
|
7
|
+
import { pipeline } from 'node:stream/promises'
|
|
6
8
|
import { fileURLToPath } from 'node:url'
|
|
7
9
|
import FileType from 'file-type'
|
|
8
10
|
|
|
@@ -103,6 +105,23 @@ function collectMediaUrls(source: unknown, result: Set<string>) {
|
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
// 限制同时下载的媒体数量,避免一条含大量图片/视频的消息一次性吃满内存和连接
|
|
109
|
+
async function forEachConcurrent<T>(
|
|
110
|
+
items: readonly T[],
|
|
111
|
+
limit: number,
|
|
112
|
+
task: (item: T) => Promise<unknown>,
|
|
113
|
+
) {
|
|
114
|
+
let cursor = 0
|
|
115
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
116
|
+
while (cursor < items.length) {
|
|
117
|
+
const current = cursor
|
|
118
|
+
cursor += 1
|
|
119
|
+
await task(items[current]).catch(() => undefined)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
await Promise.all(workers)
|
|
123
|
+
}
|
|
124
|
+
|
|
106
125
|
export class MediaManager {
|
|
107
126
|
private mediaDir: string
|
|
108
127
|
private uploadDir: string
|
|
@@ -212,7 +231,7 @@ export class MediaManager {
|
|
|
212
231
|
async cacheMessageMedia(source: unknown, channelId = ''): Promise<string[]> {
|
|
213
232
|
const urls = new Set<string>()
|
|
214
233
|
collectMediaUrls(source, urls)
|
|
215
|
-
await
|
|
234
|
+
await forEachConcurrent([...urls], 4, (url) => this.cacheUrl(url, channelId))
|
|
216
235
|
if (urls.size) {
|
|
217
236
|
this.logger.logInfo('消息媒体已预缓存:', urls.size, '个 URL')
|
|
218
237
|
}
|
|
@@ -256,33 +275,45 @@ export class MediaManager {
|
|
|
256
275
|
if (!response.ok) {
|
|
257
276
|
return null
|
|
258
277
|
}
|
|
259
|
-
|
|
260
|
-
const
|
|
278
|
+
if (!response.body) return null
|
|
279
|
+
const detected = await FileType.stream(Readable.fromWeb(response.body))
|
|
280
|
+
const ext = detected.fileType?.ext
|
|
281
|
+
? toExtension(detected.fileType.ext)
|
|
282
|
+
: (urlExt || '.bin')
|
|
261
283
|
const filename = `${hash}${ext}`
|
|
262
284
|
const filePath = path.join(this.mediaDir, filename)
|
|
263
|
-
await
|
|
264
|
-
|
|
285
|
+
if (await this.exists(filePath)) {
|
|
286
|
+
return this.migrateCachedMedia(url, filePath, channelId)
|
|
287
|
+
}
|
|
288
|
+
const tempPath = path.join(this.mediaDir, `${hash}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`)
|
|
289
|
+
try {
|
|
290
|
+
await pipeline(detected, createWriteStream(tempPath))
|
|
291
|
+
await fs.rename(tempPath, filePath)
|
|
292
|
+
} catch (error) {
|
|
293
|
+
await fs.unlink(tempPath).catch(() => undefined)
|
|
294
|
+
throw error
|
|
295
|
+
}
|
|
296
|
+
this.logger.logInfo('媒体已缓存:', filename)
|
|
265
297
|
return filePath
|
|
266
298
|
} catch (error) {
|
|
267
299
|
return null
|
|
268
300
|
}
|
|
269
301
|
}
|
|
270
302
|
|
|
271
|
-
private async
|
|
303
|
+
private async detectExtensionFromFile(filePath: string): Promise<string> {
|
|
272
304
|
try {
|
|
273
|
-
const detected = await FileType.
|
|
305
|
+
const detected = await FileType.fromStream(createReadStream(filePath))
|
|
274
306
|
if (detected?.ext) return toExtension(detected.ext)
|
|
275
307
|
} catch (error) {
|
|
276
|
-
this.logger.logInfo('
|
|
308
|
+
this.logger.logInfo('媒体类型识别失败,回退文件后缀:', filePath, error)
|
|
277
309
|
}
|
|
278
|
-
const ext = path.extname(
|
|
310
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
279
311
|
return ext && ext !== '.bin' ? ext.toLowerCase() : '.bin'
|
|
280
312
|
}
|
|
281
313
|
|
|
282
314
|
private async migrateCachedMedia(url: string, filePath: string, channelId: string): Promise<string> {
|
|
283
315
|
try {
|
|
284
|
-
const
|
|
285
|
-
const ext = await this.detectExtension(url, buffer)
|
|
316
|
+
const ext = await this.detectExtensionFromFile(filePath)
|
|
286
317
|
const currentExt = path.extname(filePath).toLowerCase()
|
|
287
318
|
if (ext === '.bin' || ext === currentExt) return filePath
|
|
288
319
|
|
package/src/recorder.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Config } from './config'
|
|
2
1
|
import { ContactCacheService } from './cache'
|
|
3
2
|
import { ChatDatabase } from './database'
|
|
4
3
|
import { MediaManager } from './media'
|
|
@@ -35,7 +34,6 @@ function isPrivateChannelType(value: unknown): boolean {
|
|
|
35
34
|
|
|
36
35
|
export class Recorder {
|
|
37
36
|
constructor(
|
|
38
|
-
private config: Config,
|
|
39
37
|
private database: ChatDatabase,
|
|
40
38
|
private media: MediaManager,
|
|
41
39
|
private contactCache: ContactCacheService,
|
|
@@ -47,7 +45,6 @@ export class Recorder {
|
|
|
47
45
|
const type = getString(body.type)
|
|
48
46
|
const login = getObject(body.login)
|
|
49
47
|
const platform = getString(body.platform) || getString(login.platform)
|
|
50
|
-
if (this.isBlocked(platform)) return false
|
|
51
48
|
if (type === 'message-deleted') {
|
|
52
49
|
await this.handleMessageDeleted(body)
|
|
53
50
|
return true
|
|
@@ -57,14 +54,6 @@ export class Recorder {
|
|
|
57
54
|
return true
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
private isBlocked(platform: string): boolean {
|
|
61
|
-
return (this.config.blockedPlatforms ?? []).some((item) => {
|
|
62
|
-
return item.exactMatch
|
|
63
|
-
? platform === item.platformName
|
|
64
|
-
: platform.includes(item.platformName)
|
|
65
|
-
})
|
|
66
|
-
}
|
|
67
|
-
|
|
68
57
|
private async handleMessageCreated(body: Record<string, unknown>) {
|
|
69
58
|
const login = getObject(body.login)
|
|
70
59
|
const loginUser = getObject(login.user)
|
package/src/satori.ts
CHANGED
|
@@ -2,12 +2,15 @@ import { Context } from 'koishi'
|
|
|
2
2
|
import {} from '@koishijs/plugin-server'
|
|
3
3
|
import {} from '@satorijs/plugin-server'
|
|
4
4
|
|
|
5
|
+
function getSatoriPath(ctx: Context): string {
|
|
6
|
+
const path = ctx.satori?.server?.config?.path ?? '/satori'
|
|
7
|
+
return path.startsWith('/') ? path : `/${path}`
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export function resolveSatoriEndpoint(ctx: Context): string {
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const base = ctx.server?.selfUrl ?? ctx.server?.config?.selfUrl ?? ''
|
|
10
|
-
return `${base}${clean.startsWith('/') ? clean : `/${clean}`}`
|
|
11
|
+
const port = ctx.server?.port ?? ctx.server?.config?.port
|
|
12
|
+
const base = `http://localhost${port ? `:${port}` : ''}`
|
|
13
|
+
return `${base}${getSatoriPath(ctx)}`
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export function toSatoriEventUrl(endpoint: string): string {
|
package/src/self-message.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Bot, Context, h, Session } from 'koishi'
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto'
|
|
3
3
|
|
|
4
|
-
import { Config } from './config'
|
|
5
4
|
import { ChatDatabase } from './database'
|
|
6
5
|
import { MediaManager } from './media'
|
|
7
6
|
import { PluginLogger } from './logger'
|
|
@@ -213,7 +212,6 @@ export class SelfMessageRecorder {
|
|
|
213
212
|
|
|
214
213
|
constructor(
|
|
215
214
|
private ctx: Context,
|
|
216
|
-
private config: Config,
|
|
217
215
|
private database: ChatDatabase,
|
|
218
216
|
private media: MediaManager,
|
|
219
217
|
private logger: PluginLogger,
|
|
@@ -236,14 +234,6 @@ export class SelfMessageRecorder {
|
|
|
236
234
|
}
|
|
237
235
|
}
|
|
238
236
|
|
|
239
|
-
private isBlocked(platform: string): boolean {
|
|
240
|
-
return (this.config.blockedPlatforms ?? []).some((item) => {
|
|
241
|
-
return item.exactMatch
|
|
242
|
-
? platform === item.platformName
|
|
243
|
-
: platform.includes(item.platformName)
|
|
244
|
-
})
|
|
245
|
-
}
|
|
246
|
-
|
|
247
237
|
// 包装三个发送入口,覆盖 Satori message.create 和插件直接发送
|
|
248
238
|
private wrapBot(bot: Bot) {
|
|
249
239
|
if (this.wrapped.has(bot)) return
|
|
@@ -337,7 +327,7 @@ export class SelfMessageRecorder {
|
|
|
337
327
|
mode: 'create' | 'send' | 'private',
|
|
338
328
|
localId: string,
|
|
339
329
|
) {
|
|
340
|
-
if (!channelId
|
|
330
|
+
if (!channelId) return
|
|
341
331
|
if (!ids.length) {
|
|
342
332
|
this.logger.warn('发送未返回消息 ID,判定为发送失败:', bot.platform, channelId)
|
|
343
333
|
return
|
|
@@ -362,7 +352,7 @@ export class SelfMessageRecorder {
|
|
|
362
352
|
}
|
|
363
353
|
|
|
364
354
|
private async markSelfMessageRevoked(bot: Bot, channelId: string, messageId: string) {
|
|
365
|
-
if (!messageId || !channelId
|
|
355
|
+
if (!messageId || !channelId) return
|
|
366
356
|
const updated = await this.database.updateSelfMessageByMessageId(
|
|
367
357
|
bot.platform ?? '',
|
|
368
358
|
bot.selfId,
|
|
@@ -384,7 +374,7 @@ export class SelfMessageRecorder {
|
|
|
384
374
|
const eventChannel = getObject(event.channel)
|
|
385
375
|
const eventMessage = getObject(event.message)
|
|
386
376
|
const channelId = session.channelId || getString(eventChannel.id)
|
|
387
|
-
if (
|
|
377
|
+
if (!platform || !selfId || !channelId) return
|
|
388
378
|
|
|
389
379
|
const messageId = session.messageId || getMessageId(eventMessage)
|
|
390
380
|
if (messageId) {
|
package/src/server.d.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
|
|
2
2
|
|
|
3
|
-
type ServerContext = ParameterizedContext<DefaultState, DefaultContext>
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
type ServerContext = ParameterizedContext<DefaultState, DefaultContext>
|
|
4
|
+
|
|
5
|
+
interface WNodeService {
|
|
6
|
+
import<T>(packageName: string, options?: {
|
|
7
|
+
allowInstall?: boolean
|
|
8
|
+
useRequire?: boolean
|
|
9
|
+
version?: string
|
|
10
|
+
}): Promise<T>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare module 'koishi' {
|
|
14
|
+
interface Context {
|
|
15
|
+
node: WNodeService
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare module '@satorijs/core' {
|
|
6
20
|
interface Satori {
|
|
7
|
-
server: {
|
|
8
|
-
url: string
|
|
9
|
-
config: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
server: {
|
|
22
|
+
url: string
|
|
23
|
+
config: {
|
|
24
|
+
path: string
|
|
25
|
+
token?: string
|
|
26
|
+
}
|
|
27
|
+
}
|
|
13
28
|
}
|
|
14
29
|
}
|
|
15
30
|
|