koishi-plugin-chat-patch 5.6.1 → 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)
@@ -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,8 +1,22 @@
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
21
  server: {
8
22
  url: string
package/src/web.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Context } from 'koishi'
2
2
  import {} from '@koishijs/plugin-server'
3
- import { createReadStream, existsSync, promises as fs, statSync } from 'node:fs'
3
+ import { createReadStream, createWriteStream, existsSync, promises as fs, statSync } from 'node:fs'
4
4
  import { createHash } from 'node:crypto'
5
5
  import path from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
- import send from 'koa-send'
8
- import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
7
+ import send from 'koa-send'
8
+ import type Router from '@koa/router'
9
+ import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
9
10
  import FileType from 'file-type'
10
11
 
11
12
  import { Config } from './config'
@@ -13,7 +14,7 @@ import { ContactCacheService } from './cache'
13
14
  import { ChatDatabase } from './database'
14
15
  import { MediaManager } from './media'
15
16
  import { PluginLogger } from './logger'
16
- import { ContactCacheItem, SelfMessagePayload, SelfMessageRecord } from './types'
17
+ import { ContactCacheItem, MessageRecord, SelfMessagePayload, SelfMessageRecord } from './types'
17
18
 
18
19
  interface ViteConsoleServer {
19
20
  config?: {
@@ -42,7 +43,7 @@ export function registerWeb(
42
43
  const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
43
44
  const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
44
45
 
45
- type WebContext = ParameterizedContext<DefaultState, DefaultContext>
46
+ type WebContext = Router.RouterContext<DefaultState, DefaultContext>
46
47
  const cacheType = (type: string) => type === 'user' ? 'friend' : type
47
48
 
48
49
  const getVite = (): ViteConsoleServer | undefined => {
@@ -133,16 +134,21 @@ export function registerWeb(
133
134
  }
134
135
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
135
136
 
136
- ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
137
- const requestBody = (koa.request as unknown as { body?: unknown }).body
138
- const body = (requestBody ?? {}) as Record<string, unknown>
139
- let source = String(body.dataUrl ?? body.data ?? '')
140
- let name = String(body.name ?? '')
141
- let mime = ''
142
- let buffer: Buffer | null = null
143
- if (source) {
144
- if (source.startsWith('base64://')) source = source.slice(9)
145
- const comma = source.indexOf('base64,')
137
+ ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
138
+ const requestBody = (koa.request as unknown as { body?: unknown }).body
139
+ const body = (requestBody ?? {}) as Record<string, unknown>
140
+ let source = String(body.dataUrl ?? body.data ?? '')
141
+ let name = String(body.name ?? '')
142
+ let mime = ''
143
+ let buffer: Buffer | null = null
144
+ let tempPath = ''
145
+ const rawHash = createHash('md5')
146
+ const cleanupTemp = async () => {
147
+ if (tempPath) await fs.unlink(tempPath).catch(() => undefined)
148
+ }
149
+ if (source) {
150
+ if (source.startsWith('base64://')) source = source.slice(9)
151
+ const comma = source.indexOf('base64,')
146
152
  const base64 = comma >= 0 ? source.slice(comma + 7) : source
147
153
  const normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
148
154
  try {
@@ -152,60 +158,86 @@ export function registerWeb(
152
158
  koa.body = { error: 'invalid media data' }
153
159
  return
154
160
  }
155
- mime = source.startsWith('data:')
156
- ? source.slice(5, source.indexOf(';')).toLowerCase()
157
- : ''
158
- } else {
159
- const chunks: Buffer[] = []
160
- for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
161
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
162
- }
163
- buffer = Buffer.concat(chunks)
164
- name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
165
- try {
166
- name = decodeURIComponent(name)
161
+ mime = source.startsWith('data:')
162
+ ? source.slice(5, source.indexOf(';')).toLowerCase()
163
+ : ''
164
+ } else {
165
+ tempPath = path.join(uploadDir, `.upload-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`)
166
+ const write = createWriteStream(tempPath)
167
+ try {
168
+ for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
169
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
170
+ rawHash.update(part)
171
+ if (!write.write(part)) {
172
+ await new Promise<void>((resolve) => write.once('drain', resolve))
173
+ }
174
+ }
175
+ await new Promise<void>((resolve, reject) => {
176
+ write.once('error', reject)
177
+ write.end(() => resolve())
178
+ })
179
+ } catch (error) {
180
+ write.destroy()
181
+ await cleanupTemp()
182
+ throw error
183
+ }
184
+ name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
185
+ try {
186
+ name = decodeURIComponent(name)
167
187
  } catch {
168
188
  // 保持原始名称即可
169
189
  }
170
- mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
171
- }
172
- if (!buffer || !buffer.length) {
173
- koa.status = 400
174
- koa.body = { error: 'missing media data' }
175
- return
176
- }
177
- if (buffer.length > MAX_UPLOAD_BYTES) {
178
- koa.status = 413
179
- koa.body = { error: 'media file too large' }
180
- return
181
- }
182
- if (!buffer.length) {
183
- koa.status = 400
184
- koa.body = { error: 'invalid media data' }
185
- return
186
- }
187
-
188
- await fs.mkdir(uploadDir, { recursive: true })
189
- let detected: { ext?: string } | undefined
190
- try {
191
- const result = await FileType.fromBuffer(buffer)
192
- detected = result ?? undefined
193
- } catch {
194
- // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
195
- detected = undefined
190
+ mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
191
+ }
192
+ const fileSize = buffer ? buffer.length : existsSync(tempPath) ? statSync(tempPath).size : 0
193
+ if (!fileSize) {
194
+ await cleanupTemp()
195
+ koa.status = 400
196
+ koa.body = { error: 'missing media data' }
197
+ return
198
+ }
199
+ if (fileSize > MAX_UPLOAD_BYTES) {
200
+ await cleanupTemp()
201
+ koa.status = 413
202
+ koa.body = { error: 'media file too large' }
203
+ return
204
+ }
205
+
206
+ await fs.mkdir(uploadDir, { recursive: true })
207
+ let detected: { ext?: string } | undefined
208
+ try {
209
+ const result = buffer
210
+ ? await FileType.fromBuffer(buffer)
211
+ : await FileType.fromStream(createReadStream(tempPath))
212
+ detected = result ?? undefined
213
+ } catch {
214
+ // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
215
+ detected = undefined
196
216
  }
197
217
  const mimeExt = mimeToExt[mime] || ''
198
218
  const nameExt = path.extname(name).toLowerCase()
199
- const ext = detected?.ext
200
- ? toExtension(detected.ext)
201
- : mimeExt || nameExt || '.bin'
202
- const filename = `${createHash('md5').update(buffer).digest('hex')}${ext}`
203
- const filePath = path.join(uploadDir, filename)
204
- if (!existsSync(filePath)) {
205
- await fs.writeFile(filePath, buffer)
206
- }
207
- koa.body = {
208
- path: pathToFileURL(filePath).href,
219
+ const ext = detected?.ext
220
+ ? toExtension(detected.ext)
221
+ : mimeExt || nameExt || '.bin'
222
+ const digest = buffer ? createHash('md5').update(buffer).digest('hex') : rawHash.digest('hex')
223
+ const filename = `${digest}${ext}`
224
+ const filePath = path.join(uploadDir, filename)
225
+ try {
226
+ if (!existsSync(filePath)) {
227
+ if (buffer) {
228
+ await fs.writeFile(filePath, buffer)
229
+ } else {
230
+ await fs.rename(tempPath, filePath)
231
+ tempPath = ''
232
+ }
233
+ }
234
+ } catch (error) {
235
+ await cleanupTemp()
236
+ throw error
237
+ }
238
+ await cleanupTemp()
239
+ koa.body = {
240
+ path: pathToFileURL(filePath).href,
209
241
  localPath: filePath,
210
242
  }
211
243
  })
@@ -303,8 +335,12 @@ export function registerWeb(
303
335
  koa.body = { error: 'missing history params' }
304
336
  return
305
337
  }
306
- const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel)
307
- const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel
338
+ const defaultLimit = Math.min(config.maxMessagesPerChannel, 100)
339
+ const parsedLimit = Number(koa.query.limit ?? defaultLimit)
340
+ const limit = Math.min(
341
+ Math.max(1, Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.floor(parsedLimit) : defaultLimit),
342
+ config.maxMessagesPerChannel,
343
+ )
308
344
  const beforeTime = Number(koa.query.beforeTime ?? 0)
309
345
  const queryMessages = async (cid: string) => {
310
346
  return Number.isFinite(beforeTime) && beforeTime > 0
@@ -331,10 +367,35 @@ export function registerWeb(
331
367
  const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
332
368
  const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
333
369
  return timeA - timeB
334
- })
335
- selfMessages.sort((a, b) => a.sentAt - b.sentAt)
336
- koa.body = { messages, selfMessages }
337
- })
370
+ })
371
+ selfMessages.sort((a, b) => a.sentAt - b.sentAt)
372
+ // 合并两条记录流后只返回 limit 条,保证前端分页/结束判断不会因自消息被放大
373
+ const combined: Array<{
374
+ kind: 'message' | 'self'
375
+ time: number
376
+ record: MessageRecord | SelfMessageRecord
377
+ }> = []
378
+ for (const item of messages) {
379
+ combined.push({
380
+ kind: 'message',
381
+ time: Number(item?.receivedAt ?? item?.timestampMs ?? item?.timestamp ?? 0),
382
+ record: item,
383
+ })
384
+ }
385
+ for (const item of selfMessages) {
386
+ combined.push({ kind: 'self', time: item.sentAt, record: item })
387
+ }
388
+ combined.sort((a, b) => a.time - b.time)
389
+ const capped = combined.slice(-limit)
390
+ koa.body = {
391
+ messages: capped
392
+ .filter((item) => item.kind === 'message')
393
+ .map((item) => item.record as MessageRecord),
394
+ selfMessages: capped
395
+ .filter((item) => item.kind === 'self')
396
+ .map((item) => item.record as SelfMessageRecord),
397
+ }
398
+ })
338
399
 
339
400
  ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
340
401
  const platform = String(koa.query.platform ?? '')
@@ -685,15 +746,20 @@ export function registerWeb(
685
746
  })
686
747
 
687
748
  const serveFile = async (koa: WebContext, fileName: string) => {
688
- const fullPath = path.join(webRoot, fileName)
689
- if (existsSync(fullPath) && statSync(fullPath).isFile()) {
690
- await send(koa, path.relative(webRoot, fullPath), { root: webRoot })
691
- return
692
- }
693
- koa.status = 200
694
- koa.body = ''
695
- await send(koa, 'index.html', { root: webRoot })
696
- }
749
+ const fullPath = path.join(webRoot, fileName)
750
+ if (existsSync(fullPath) && statSync(fullPath).isFile()) {
751
+ const rootContext = koa as unknown as ParameterizedContext<DefaultState, DefaultContext>
752
+ await send(rootContext, path.relative(webRoot, fullPath), { root: webRoot })
753
+ return
754
+ }
755
+ koa.status = 200
756
+ koa.body = ''
757
+ await send(
758
+ koa as unknown as ParameterizedContext<DefaultState, DefaultContext>,
759
+ 'index.html',
760
+ { root: webRoot },
761
+ )
762
+ }
697
763
 
698
764
  const serveDevWeb = async (koa: WebContext): Promise<boolean> => {
699
765
  const vite = getVite()