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/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
- export { Config } from './config'
39
-
40
- export async function apply(ctx: Context, config: Config) {
41
- const pluginLogger = createPluginLogger(ctx.logger('chat-patch'), config)
42
-
43
- const database = new ChatDatabase(ctx, config, pluginLogger)
44
- await database.initialize()
45
- const contactCache = new ContactCacheService(ctx, database, pluginLogger)
46
-
47
- const media = new MediaManager(ctx, config, database, pluginLogger)
48
- media.start()
49
-
50
- const recorder = new Recorder(config, database, media, contactCache, pluginLogger)
51
- const gateway = new SatoriGateway(ctx, config, database, recorder, pluginLogger, (payload) => {
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, config, database, media, pluginLogger)
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 Promise.allSettled([...urls].map((url) => this.cacheUrl(url, channelId)))
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
- const buffer = Buffer.from(await response.arrayBuffer())
260
- const ext = await this.detectExtension(url, buffer)
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 fs.writeFile(filePath, buffer)
264
- this.logger.logInfo('媒体已缓存:', filename, buffer.length)
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 detectExtension(url: string, buffer: Buffer): Promise<string> {
303
+ private async detectExtensionFromFile(filePath: string): Promise<string> {
272
304
  try {
273
- const detected = await FileType.fromBuffer(buffer)
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('媒体类型识别失败,回退 URL 后缀:', url, error)
308
+ this.logger.logInfo('媒体类型识别失败,回退文件后缀:', filePath, error)
277
309
  }
278
- const ext = path.extname(new URL(url).pathname)
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 buffer = await fs.readFile(filePath)
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 url = ctx.satori?.server?.url ?? '/satori'
7
- const clean = url.startsWith('undefined') ? url.slice(9) : url
8
- if (/^https?:\/\//i.test(clean)) return clean
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 {
@@ -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 || this.isBlocked(bot.platform ?? '')) return
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 || this.isBlocked(bot.platform ?? '')) return
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 (this.isBlocked(platform) || !platform || !selfId || !channelId) return
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
- declare module '@satorijs/core' {
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
- token?: string
11
- }
12
- }
21
+ server: {
22
+ url: string
23
+ config: {
24
+ path: string
25
+ token?: string
26
+ }
27
+ }
13
28
  }
14
29
  }
15
30