koishi-plugin-chat-patch 5.3.1 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/client/web/dist/assets/Chat-B5Jdrz_2.js +28 -0
  2. package/client/web/dist/assets/{Chat-C-rmzMIw.css → Chat-DTEsebnY.css} +1 -1
  3. package/client/web/dist/assets/{MsgBody-h4C1nnBQ.js → MsgBody-tPnKSZOs.js} +1 -1
  4. package/client/web/dist/assets/{MsgBody.vue_vue_type_script_setup_true_lang-a902ps2r.js → MsgBody.vue_vue_type_script_setup_true_lang-ClDybRo_.js} +1 -1
  5. package/client/web/dist/assets/{append_darwin-CqBNU62J.css → append_darwin-Cql-dV6d.css} +1 -1
  6. package/client/web/dist/assets/{append_full_vibrancy-j0D2LM9m.css → append_full_vibrancy-ih3loy1O.css} +1 -1
  7. package/client/web/dist/assets/{append_linux-uhxeBqeC.css → append_linux-B3LvR8KB.css} +1 -1
  8. package/client/web/dist/assets/{append_new-407Lo5_V.css → append_new-DfhN_NX9.css} +1 -1
  9. package/client/web/dist/assets/{append_vibrancy-6FHctkOY.css → append_vibrancy-D3pVsmAa.css} +1 -1
  10. package/client/web/dist/assets/{append_win32-srmD-78y.css → append_win32-Dh9JlmlE.css} +1 -1
  11. package/client/web/dist/assets/index-B71pPwae.css +1 -0
  12. package/client/web/dist/assets/{index-CdTmWcO4.js → index-CSGfOsfr.js} +97 -76
  13. package/client/web/dist/index.html +2 -2
  14. package/client/web/src/App.vue +2 -2
  15. package/client/web/src/assets/css/append/append_darwin.css +2 -2
  16. package/client/web/src/assets/css/append/append_full_vibrancy.css +2 -2
  17. package/client/web/src/assets/css/append/append_linux.css +2 -2
  18. package/client/web/src/assets/css/append/append_new.css +2 -2
  19. package/client/web/src/assets/css/append/append_vibrancy.css +1 -1
  20. package/client/web/src/assets/css/append/append_win32.css +2 -2
  21. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +1 -1
  22. package/client/web/src/assets/css/chat.css +4 -3
  23. package/client/web/src/assets/css/msg.css +7 -3
  24. package/client/web/src/assets/css/options.css +2 -2
  25. package/client/web/src/assets/css/view.css +14 -3
  26. package/client/web/src/assets/l10n/zh-CN.po +21 -0
  27. package/client/web/src/components/AboutPan.vue +5 -3
  28. package/client/web/src/components/DepPan.vue +1 -1
  29. package/client/web/src/components/MsgBody.vue +0 -3
  30. package/client/web/src/components/ThemeColorPickerPan.vue +1 -1
  31. package/client/web/src/components/UmamiInfoPan.vue +1 -1
  32. package/client/web/src/components/WelPan.vue +1 -1
  33. package/client/web/src/function/connect.ts +232 -72
  34. package/client/web/src/function/elements/information.ts +1 -0
  35. package/client/web/src/function/msg.ts +43 -24
  36. package/client/web/src/function/satori-model.ts +47 -15
  37. package/client/web/src/function/satori.ts +2 -1
  38. package/client/web/src/function/utils/appUtil.ts +3 -11
  39. package/client/web/src/function/utils/localHistoryUtil.ts +36 -12
  40. package/client/web/src/function/utils/msgUtil.ts +32 -21
  41. package/client/web/src/function/utils/sessionUtil.ts +20 -18
  42. package/client/web/src/pages/Chat.vue +47 -43
  43. package/client/web/src/pages/Friends.vue +5 -7
  44. package/client/web/src/pages/Info.vue +2 -2
  45. package/client/web/src/pages/Messages.vue +37 -23
  46. package/client/web/src/pages/chat-view/ChatTerminal.vue +4 -1
  47. package/client/web/src/pages/options/OptDev.vue +45 -2
  48. package/lib/database.d.ts +58 -0
  49. package/lib/index.js +220 -68
  50. package/lib/media.d.ts +24 -0
  51. package/lib/recorder.d.ts +21 -0
  52. package/lib/self-message.d.ts +27 -0
  53. package/lib/types.d.ts +6 -0
  54. package/lib/web.d.ts +7 -0
  55. package/package.json +1 -1
  56. package/src/cache.ts +1 -5
  57. package/src/database.ts +52 -8
  58. package/src/index.ts +4 -4
  59. package/src/media.ts +97 -0
  60. package/src/recorder.ts +28 -19
  61. package/src/self-message.ts +68 -19
  62. package/src/types.ts +6 -0
  63. package/src/web.ts +49 -39
  64. package/client/web/dist/assets/Chat-Bo5JdcNg.js +0 -28
  65. package/client/web/dist/assets/index-wjp_A4n1.css +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-patch",
3
3
  "description": "[<ruby>chat-patch<rp>(</rp><rt>点我预览效果</rt><rp>)</rp></ruby>](https://i0.hdslb.com/bfs/openplatform/71074dfc9e5256fc3333d8bd8478bec1af874046.png) 视奸小插件((bushi( (低性能警告)。手机端适配。灵感来自 chat 插件。",
4
- "version": "5.3.1",
4
+ "version": "5.5.1",
5
5
  "scripts": {
6
6
  "build:web": "npm --prefix client/web run build",
7
7
  "prepack": "npm run build:web",
package/src/cache.ts CHANGED
@@ -13,9 +13,7 @@ function isUsableName(value: string, id: string): boolean {
13
13
  }
14
14
 
15
15
  function normalizeGroupId(value: string): string {
16
- const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
17
- const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
18
- return wrapped ? wrapped[1] : raw || value
16
+ return String(value)
19
17
  }
20
18
 
21
19
  function isPrivateChannelType(value: unknown): boolean {
@@ -146,8 +144,6 @@ export class ContactCacheService {
146
144
  const guild = guildId && guildId !== '0' ? guildId : undefined
147
145
  const channel = channelId && channelId !== '0' ? channelId : undefined
148
146
  if (isPrivateChannelType(channelType)) return null
149
- if (channelType === undefined
150
- && (/^(?:private|direct):/i.test(id) || /^(?:private|direct):/i.test(channel || ''))) return null
151
147
  const rawId = id && id !== '0' ? normalizeGroupId(id) : ''
152
148
  const cacheId = rawId || guild || normalizeGroupId(channel || '') || ''
153
149
  if (!cacheId) return null
package/src/database.ts CHANGED
@@ -58,10 +58,6 @@ function legacyGroupMemberKey(platform: string, selfId: string, groupId: string)
58
58
  return `gm:${platform}:${selfId}:${groupId}`
59
59
  }
60
60
 
61
- function isPrivateChannelId(value: unknown): boolean {
62
- return typeof value === 'string' && /^(?:private|direct):/i.test(value)
63
- }
64
-
65
61
  function isPrivateChannelType(value: unknown): boolean {
66
62
  const num = Number(value)
67
63
  if (Number.isFinite(num)) return num === 1
@@ -70,16 +66,17 @@ function isPrivateChannelType(value: unknown): boolean {
70
66
  }
71
67
 
72
68
  function isUsableGroupContact(item: ContactCacheItem): boolean {
73
- if (isPrivateChannelId(item.channelId)) return false
74
69
  if (typeof item.raw === 'object' && item.raw !== null) {
75
70
  const raw = item.raw as Record<string, unknown>
76
- if (isPrivateChannelType(raw.channel_type)
77
- || isPrivateChannelId(raw.channel_id)
78
- || isPrivateChannelId(raw.channelId)) return false
71
+ if (isPrivateChannelType(raw.channel_type)) return false
79
72
  }
80
73
  return true
81
74
  }
82
75
 
76
+ type CompactableLevel = Level<string, string> & {
77
+ compactRange(start: string, end: string): Promise<void>
78
+ }
79
+
83
80
  export class ChatDatabase {
84
81
  private db: Level<string, string>
85
82
  private opened = false
@@ -106,6 +103,14 @@ export class ChatDatabase {
106
103
  this.logger.logInfo('LevelDB closed')
107
104
  }
108
105
 
106
+ async clearAll() {
107
+ // 先清空记录,再强制压缩,让旧 .ldb 文件也能被回收
108
+ await this.db.clear()
109
+ const db = this.db as unknown as CompactableLevel
110
+ await db.compactRange('', '\uffff')
111
+ this.logger.logInfo('数据库缓存已全部清空并完成压缩')
112
+ }
113
+
109
114
  async appendMessage(record: MessageRecord) {
110
115
  await this.db.put(messageKey(record), JSON.stringify(record))
111
116
  await this.trimMessages(record.platform, record.selfId, record.channelId || '')
@@ -193,6 +198,45 @@ export class ChatDatabase {
193
198
  return false
194
199
  }
195
200
 
201
+ async updateMessageRevoked(
202
+ platform: string,
203
+ selfId: string,
204
+ channelId: string,
205
+ messageId: string,
206
+ patch: { revoked: boolean; revokedAt: number },
207
+ ): Promise<boolean> {
208
+ const operations: Array<{ type: 'put'; key: string; value: string }> = []
209
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
210
+ for await (const [key, value] of this.db.iterator<string, string>({
211
+ gte: prefix,
212
+ lte: `${prefix}\uffff`,
213
+ })) {
214
+ try {
215
+ const parsed = JSON.parse(value) as unknown
216
+ if (typeof parsed !== 'object' || parsed === null) continue
217
+ const record = parsed as Partial<MessageRecord>
218
+ const raw = typeof record.raw === 'object' && record.raw !== null
219
+ ? record.raw as Record<string, unknown>
220
+ : {}
221
+ const rawMessage = typeof raw.message === 'object' && raw.message !== null
222
+ ? raw.message as Record<string, unknown>
223
+ : {}
224
+ const id = String(record.id ?? rawMessage.id ?? '')
225
+ if (!id || id !== messageId) continue
226
+ operations.push({
227
+ type: 'put',
228
+ key,
229
+ value: JSON.stringify({ ...parsed, ...patch }),
230
+ })
231
+ } catch {
232
+ // 单条解析失败不影响其他消息
233
+ }
234
+ }
235
+ }
236
+ if (operations.length) await this.db.batch(operations)
237
+ return operations.length > 0
238
+ }
239
+
196
240
  async findSelfForwardContent(
197
241
  platform: string,
198
242
  selfId: string,
package/src/index.ts CHANGED
@@ -49,21 +49,21 @@ export async function apply(ctx: Context, config: Config) {
49
49
  const recorder = new Recorder(ctx, config, database, media, contactCache, pluginLogger)
50
50
  recorder.start()
51
51
 
52
- const selfMessages = new SelfMessageRecorder(ctx, config, database, pluginLogger)
52
+ const selfMessages = new SelfMessageRecorder(ctx, config, database, media, pluginLogger)
53
53
  selfMessages.start()
54
54
 
55
55
  registerBootstrap(ctx, config, database, pluginLogger)
56
- registerWeb(ctx, config, database, contactCache, pluginLogger)
56
+ registerWeb(ctx, config, database, contactCache, media, pluginLogger)
57
57
 
58
58
  ctx.console.addEntry({
59
59
  dev: path.resolve(__dirname, '../client/index.ts'),
60
60
  prod: path.resolve(__dirname, '../dist'),
61
61
  })
62
62
 
63
- ctx.on('dispose', () => {
63
+ ctx.on('dispose', async () => {
64
64
  selfMessages.dispose()
65
65
  media.dispose()
66
- void database.dispose()
66
+ await database.dispose()
67
67
  pluginLogger.logInfo('chat-patch 已卸载')
68
68
  })
69
69
  }
package/src/media.ts CHANGED
@@ -27,6 +27,82 @@ function toLocalFilePath(value: string): string | null {
27
27
  return null
28
28
  }
29
29
 
30
+ const MEDIA_TYPES = new Set([
31
+ 'img',
32
+ 'image',
33
+ 'face',
34
+ 'mface',
35
+ 'audio',
36
+ 'record',
37
+ 'video',
38
+ 'file',
39
+ ])
40
+
41
+ function isCacheableMediaUrl(value: unknown): string {
42
+ const url = String(value ?? '').trim()
43
+ if (!url
44
+ || url.startsWith('data:')
45
+ || url.startsWith('blob:')
46
+ || url.startsWith('base64://')) return ''
47
+ return url
48
+ }
49
+
50
+ function isUrlLike(value: string): boolean {
51
+ return /^(?:https?:\/\/|file:\/\/|[a-z]:[\\/]|\\\\)/i.test(value)
52
+ }
53
+
54
+ function decodeMarkupValue(value: string): string {
55
+ return value
56
+ .replace(/&amp;/g, '&')
57
+ .replace(/&quot;/g, '"')
58
+ .replace(/&#39;/g, "'")
59
+ .replace(/&lt;/g, '<')
60
+ .replace(/&gt;/g, '>')
61
+ }
62
+
63
+ function collectMarkupMediaUrls(markup: string, result: Set<string>) {
64
+ const pattern = /<(?:img|image|face|mface|audio|record|video|file)\b[^>]*?\b(?:src|url|file|href)\s*=\s*(["'])([\s\S]*?)\1/gi
65
+ let match: RegExpExecArray | null
66
+ while ((match = pattern.exec(markup)) !== null) {
67
+ const url = isCacheableMediaUrl(decodeMarkupValue(match[2]))
68
+ if (url) result.add(url)
69
+ }
70
+ }
71
+
72
+ function collectMediaUrls(source: unknown, result: Set<string>) {
73
+ if (typeof source === 'string') {
74
+ const direct = isCacheableMediaUrl(source)
75
+ if (direct && isUrlLike(direct)) result.add(direct)
76
+ collectMarkupMediaUrls(source, result)
77
+ try {
78
+ collectMediaUrls(JSON.parse(source) as unknown, result)
79
+ } catch {
80
+ // 普通文本或非 JSON markup 不需要二次解析
81
+ }
82
+ return
83
+ }
84
+ if (Array.isArray(source)) {
85
+ for (const item of source) collectMediaUrls(item, result)
86
+ return
87
+ }
88
+ if (typeof source !== 'object' || source === null) return
89
+
90
+ const record = source as Record<string, unknown>
91
+ const attrs = typeof record.attrs === 'object' && record.attrs !== null
92
+ ? record.attrs as Record<string, unknown>
93
+ : {}
94
+ if (MEDIA_TYPES.has(String(record.type ?? ''))) {
95
+ for (const key of ['src', 'url', 'file', 'href', 'path']) {
96
+ const url = isCacheableMediaUrl(attrs[key])
97
+ if (url) result.add(url)
98
+ }
99
+ }
100
+
101
+ for (const key of ['elements', 'children', 'content', 'data', 'html', 'message'] as const) {
102
+ collectMediaUrls(attrs[key] ?? record[key], result)
103
+ }
104
+ }
105
+
30
106
  export class MediaManager {
31
107
  private mediaDir: string
32
108
  private uploadDir: string
@@ -109,6 +185,16 @@ export class MediaManager {
109
185
  this.cleanupTimer?.()
110
186
  }
111
187
 
188
+ async clearAll() {
189
+ const root = path.resolve(this.ctx.baseDir, 'data', 'chat-patch')
190
+ for (const dir of [this.mediaDir, this.uploadDir]) {
191
+ const resolved = path.resolve(dir)
192
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) continue
193
+ await fs.rm(resolved, { recursive: true, force: true })
194
+ }
195
+ this.logger.logInfo('媒体缓存目录已全部清空')
196
+ }
197
+
112
198
  async cacheUrl(url: string, channelId = ''): Promise<string | null> {
113
199
  try {
114
200
  const filePath = await this.resolveAndDownload(url, channelId)
@@ -122,6 +208,17 @@ export class MediaManager {
122
208
  }
123
209
  }
124
210
 
211
+ // 收到或发送消息时立即预缓存富媒体,避免等前端渲染时原 URL 已过期
212
+ async cacheMessageMedia(source: unknown, channelId = ''): Promise<string[]> {
213
+ const urls = new Set<string>()
214
+ collectMediaUrls(source, urls)
215
+ await Promise.allSettled([...urls].map((url) => this.cacheUrl(url, channelId)))
216
+ if (urls.size) {
217
+ this.logger.logInfo('消息媒体已预缓存:', urls.size, '个 URL')
218
+ }
219
+ return [...urls]
220
+ }
221
+
125
222
  private async resolveAndDownload(url: string, channelId: string): Promise<string | null> {
126
223
  try {
127
224
  await fs.mkdir(this.mediaDir, { recursive: true })
package/src/recorder.ts CHANGED
@@ -10,9 +10,7 @@ import { PluginLogger } from './logger'
10
10
  const MESSAGE_TYPES = new Set(['message', 'message-created'])
11
11
 
12
12
  function normalizeGroupId(value: string): string {
13
- const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
14
- const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
15
- return wrapped ? wrapped[1] : raw || value
13
+ return String(value)
16
14
  }
17
15
 
18
16
  export class Recorder {
@@ -27,6 +25,12 @@ export class Recorder {
27
25
 
28
26
  start() {
29
27
  this.ctx.on('internal/session', (session: Session) => {
28
+ if (session.type === 'message-deleted') {
29
+ void this.handleMessageDeleted(session).catch((error) => {
30
+ this.logger.warn('处理消息撤回失败:', error)
31
+ })
32
+ return
33
+ }
30
34
  void this.handleSession(session)
31
35
  })
32
36
  }
@@ -76,6 +80,26 @@ export class Recorder {
76
80
  })
77
81
  }
78
82
 
83
+ private async handleMessageDeleted(session: Session) {
84
+ const platform = session.platform || 'unknown'
85
+ if (this.isBlocked(platform)) return
86
+ const event = session.toJSON()
87
+ const rawMessage = typeof event.message === 'object' && event.message !== null
88
+ ? event.message as Record<string, unknown>
89
+ : {}
90
+ const messageId = session.messageId || String(rawMessage.id ?? '')
91
+ const channelId = String(
92
+ event.channel?.id
93
+ || event.guild?.id
94
+ || session.channelId
95
+ || '',
96
+ )
97
+ if (!messageId || !channelId) return
98
+ const patch = { revoked: true, revokedAt: Date.now() }
99
+ await this.database.updateMessageRevoked(platform, session.selfId, channelId, messageId, patch)
100
+ await this.database.updateSelfMessageByMessageId(platform, session.selfId, channelId, messageId, patch)
101
+ }
102
+
79
103
  private async cacheMessageContacts(event: ReturnType<Session['toJSON']>) {
80
104
  const platform = event.platform || ''
81
105
  const selfId = event.selfId || ''
@@ -89,7 +113,6 @@ export class Recorder {
89
113
  const channelType = String(channel?.type ?? '')
90
114
  const isPrivateChannel = channelType === '1'
91
115
  || ['direct', 'private'].includes(channelType.toLowerCase())
92
- || (!channelType && /^private:/i.test(channelId))
93
116
  const groupId = guildId || (isPrivateChannel ? '' : normalizeGroupId(channelId)) || ''
94
117
  const userName = user?.name || user?.nick || member?.nick || member?.name || ''
95
118
  const userAvatar = user?.avatar || member?.avatar || ''
@@ -151,25 +174,11 @@ export class Recorder {
151
174
  }
152
175
 
153
176
  private async cacheMessageMedia(event: ReturnType<Session['toJSON']>) {
154
- const elements = Array.isArray(event.message?.elements) ? event.message.elements : []
155
177
  const channelId = String(
156
178
  event.channel?.id
157
179
  || event.guild?.id
158
180
  || '',
159
181
  )
160
- const urls: string[] = []
161
- const collect = (list: unknown[]) => {
162
- for (const raw of list) {
163
- const element = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
164
- const attrs = typeof element.attrs === 'object' && element.attrs !== null ? element.attrs as Record<string, unknown> : {}
165
- if (['img', 'image', 'mface', 'audio', 'video', 'file'].includes(String(element.type))) {
166
- const url = String(attrs.src ?? attrs.url ?? attrs.file ?? '')
167
- if (url) urls.push(url)
168
- }
169
- if (Array.isArray(element.children)) collect(element.children)
170
- }
171
- }
172
- collect(elements)
173
- await Promise.allSettled(urls.map((url) => this.media.cacheUrl(url, channelId)))
182
+ await this.media.cacheMessageMedia(event.message, channelId)
174
183
  }
175
184
  }
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'
3
3
 
4
4
  import { Config } from './config'
5
5
  import { ChatDatabase } from './database'
6
+ import { MediaManager } from './media'
6
7
  import { PluginLogger } from './logger'
7
8
  import { SelfMessageChannelType, SelfMessageRecord, SelfMessageSource } from './types'
8
9
 
@@ -14,11 +15,13 @@ type SendOptionsWithMeta = SendOptions & {
14
15
  type BotSendMethod = Bot['sendMessage']
15
16
  type BotCreateMethod = Bot['createMessage']
16
17
  type BotPrivateMethod = Bot['sendPrivateMessage']
18
+ type BotDeleteMethod = Bot['deleteMessage']
17
19
 
18
20
  interface BotMethods {
19
21
  createMessage: BotCreateMethod
20
22
  sendMessage: BotSendMethod
21
23
  sendPrivateMessage: BotPrivateMethod
24
+ deleteMessage: BotDeleteMethod
22
25
  }
23
26
 
24
27
  interface SatoriElement {
@@ -38,6 +41,11 @@ function getString(value: unknown): string {
38
41
  return typeof value === 'string' ? value : ''
39
42
  }
40
43
 
44
+ function getMessageId(value: unknown): string {
45
+ const raw = getObject(value).id ?? getObject(value).message_id
46
+ return raw == null ? '' : String(raw)
47
+ }
48
+
41
49
  function getNumber(value: unknown): number {
42
50
  const num = Number(value)
43
51
  return Number.isFinite(num) ? num : 0
@@ -51,8 +59,9 @@ function getObject(value: unknown): Record<string, unknown> {
51
59
 
52
60
  function toSatoriElement(value: unknown): SatoriElement {
53
61
  const raw = getObject(value)
54
- const attrs = typeof raw.attrs === 'object' && raw.attrs !== null
55
- ? raw.attrs as Record<string, unknown>
62
+ const rawAttrs = raw.attrs ?? raw.data
63
+ const attrs = typeof rawAttrs === 'object' && rawAttrs !== null
64
+ ? rawAttrs as Record<string, unknown>
56
65
  : {}
57
66
  const children = Array.isArray(raw.children)
58
67
  ? raw.children.map(toSatoriElement)
@@ -71,10 +80,6 @@ function isForwardContainer(element: SatoriElement): boolean {
71
80
  && ('forward' in (element.attrs ?? {}) || element.attrs?.forward === true))
72
81
  }
73
82
 
74
- function isPrivateChannel(value: string): boolean {
75
- return /^(?:private|direct):/i.test(value)
76
- }
77
-
78
83
  function toSegments(
79
84
  elements: unknown[],
80
85
  resolveI18n?: (attrs: Record<string, unknown>) => string,
@@ -85,9 +90,9 @@ function toSegments(
85
90
  const attrs = element.attrs ?? {}
86
91
  const type = getString(element.type)
87
92
  if (type === 'text') {
88
- result.push({ type: 'text', text: getString(attrs.content) })
93
+ result.push({ type: 'text', text: getString(attrs.content) || getString(attrs.text) })
89
94
  } else if (type === 'at') {
90
- const id = getString(attrs.id)
95
+ const id = getString(attrs.id) || getString(attrs.qq)
91
96
  const name = getString(attrs.name) || (attrs.type === 'all' ? '所有人' : id)
92
97
  result.push({ type: 'at', qq: id, text: name.startsWith('@') ? name : `@${name}` })
93
98
  } else if (type === 'img' || type === 'image') {
@@ -210,6 +215,7 @@ export class SelfMessageRecorder {
210
215
  private ctx: Context,
211
216
  private config: Config,
212
217
  private database: ChatDatabase,
218
+ private media: MediaManager,
213
219
  private logger: PluginLogger,
214
220
  ) {}
215
221
 
@@ -245,6 +251,7 @@ export class SelfMessageRecorder {
245
251
  createMessage: bot.createMessage,
246
252
  sendMessage: bot.sendMessage,
247
253
  sendPrivateMessage: bot.sendPrivateMessage,
254
+ deleteMessage: bot.deleteMessage,
248
255
  }
249
256
  this.wrapped.set(bot, methods)
250
257
 
@@ -258,7 +265,7 @@ export class SelfMessageRecorder {
258
265
  try {
259
266
  const messages = await methods.createMessage.call(bot, channelId, content, referrer, sendOptions)
260
267
  const ids = Array.isArray(messages)
261
- ? messages.map((item) => getString(getObject(item).id)).filter(Boolean)
268
+ ? messages.map((item) => getMessageId(item)).filter((id) => id !== '')
262
269
  : []
263
270
  await this.recordFromWrapper(bot, channelId, content, ids, options, 'create', localId)
264
271
  return messages
@@ -267,6 +274,11 @@ export class SelfMessageRecorder {
267
274
  }
268
275
  }
269
276
 
277
+ bot.deleteMessage = async (channelId, messageId) => {
278
+ await methods.deleteMessage.call(bot, channelId, messageId)
279
+ await this.markSelfMessageRevoked(bot, channelId, messageId)
280
+ }
281
+
270
282
  bot.sendMessage = async (channelId, content, referrer, options) => {
271
283
  const sendOptions = this.prepareOptions(options)
272
284
  if (sendOptions.chatPatchId) {
@@ -275,7 +287,8 @@ export class SelfMessageRecorder {
275
287
  const localId = randomUUID()
276
288
  sendOptions.chatPatchId = localId
277
289
  try {
278
- const ids = await methods.sendMessage.call(bot, channelId, content, referrer, sendOptions)
290
+ const ids = (await methods.sendMessage.call(bot, channelId, content, referrer, sendOptions))
291
+ .filter((id) => id !== '')
279
292
  await this.recordFromWrapper(bot, channelId, content, ids, options, 'send', localId)
280
293
  return ids
281
294
  } finally {
@@ -291,7 +304,8 @@ export class SelfMessageRecorder {
291
304
  const localId = randomUUID()
292
305
  sendOptions.chatPatchId = localId
293
306
  try {
294
- const ids = await methods.sendPrivateMessage.call(bot, userId, content, guildId, sendOptions)
307
+ const ids = (await methods.sendPrivateMessage.call(bot, userId, content, guildId, sendOptions))
308
+ .filter((id) => id !== '')
295
309
  await this.recordFromWrapper(bot, userId, content, ids, options, 'private', localId)
296
310
  return ids
297
311
  } finally {
@@ -306,6 +320,7 @@ export class SelfMessageRecorder {
306
320
  bot.createMessage = methods.createMessage
307
321
  bot.sendMessage = methods.sendMessage
308
322
  bot.sendPrivateMessage = methods.sendPrivateMessage
323
+ bot.deleteMessage = methods.deleteMessage
309
324
  this.wrapped.delete(bot)
310
325
  }
311
326
 
@@ -323,6 +338,10 @@ export class SelfMessageRecorder {
323
338
  localId: string,
324
339
  ) {
325
340
  if (!channelId || this.isBlocked(bot.platform ?? '')) return
341
+ if (!ids.length) {
342
+ this.logger.warn('发送未返回消息 ID,判定为发送失败:', bot.platform, channelId)
343
+ return
344
+ }
326
345
  const source: SelfMessageSource = options?.session ? 'plugin' : 'bot'
327
346
  const record = this.buildRecord(
328
347
  bot.platform ?? '',
@@ -336,9 +355,26 @@ export class SelfMessageRecorder {
336
355
  localId,
337
356
  )
338
357
  await this.database.upsertSelfMessage(record)
358
+ void this.media.cacheMessageMedia(content, channelId).catch((error) => {
359
+ this.logger.warn('缓存机器人消息媒体失败:', error)
360
+ })
339
361
  this.logger.logInfo('机器人消息已记录:', record.kind, record.channelId, record.messageId || record.id)
340
362
  }
341
363
 
364
+ private async markSelfMessageRevoked(bot: Bot, channelId: string, messageId: string) {
365
+ if (!messageId || !channelId || this.isBlocked(bot.platform ?? '')) return
366
+ const updated = await this.database.updateSelfMessageByMessageId(
367
+ bot.platform ?? '',
368
+ bot.selfId,
369
+ channelId,
370
+ messageId,
371
+ { revoked: true, revokedAt: Date.now() },
372
+ )
373
+ if (!updated) {
374
+ this.logger.warn('撤回消息未找到本地自消息记录:', bot.platform, channelId, messageId)
375
+ }
376
+ }
377
+
342
378
  // 收到 send 事件时只补消息 id,避免重复记录
343
379
  private async recordSendEvent(session: Session) {
344
380
  if (session.type !== 'send') return
@@ -350,7 +386,7 @@ export class SelfMessageRecorder {
350
386
  const channelId = session.channelId || getString(eventChannel.id)
351
387
  if (this.isBlocked(platform) || !platform || !selfId || !channelId) return
352
388
 
353
- const messageId = session.messageId || getString(eventMessage.id)
389
+ const messageId = session.messageId || getMessageId(eventMessage)
354
390
  if (messageId) {
355
391
  const updated = await this.database.updateSelfMessageByMessageId(
356
392
  platform,
@@ -376,6 +412,9 @@ export class SelfMessageRecorder {
376
412
  randomUUID(),
377
413
  )
378
414
  await this.database.upsertSelfMessage(record)
415
+ void this.media.cacheMessageMedia(session.content ?? '', channelId).catch((error) => {
416
+ this.logger.warn('缓存 send 事件媒体失败:', error)
417
+ })
379
418
  }
380
419
 
381
420
  private resolveI18nElement(attrs: Record<string, unknown>): string {
@@ -404,10 +443,9 @@ export class SelfMessageRecorder {
404
443
  mode: 'create' | 'send' | 'private',
405
444
  localId: string,
406
445
  ): SelfMessageRecord {
407
- const normalizedChannelId = mode === 'private' && !isPrivateChannel(channelId)
408
- ? `private:${channelId}`
409
- : channelId
410
- const channelType: SelfMessageChannelType = mode === 'private' || isPrivateChannel(normalizedChannelId)
446
+ const normalizedChannelId = channelId
447
+ // 只按调用入口判断私聊,不根据频道 ID 前缀推断
448
+ const channelType: SelfMessageChannelType = mode === 'private'
411
449
  ? 'user'
412
450
  : 'group'
413
451
  const elements = this.normalizeElements(content)
@@ -445,9 +483,20 @@ export class SelfMessageRecorder {
445
483
 
446
484
  private normalizeElements(content: MessageContent): unknown[] {
447
485
  try {
448
- const elements = typeof content === 'string'
449
- ? h.parse(content)
450
- : h.toElementArray(content)
486
+ const normalized = typeof content === 'string'
487
+ ? content
488
+ : Array.isArray(content)
489
+ ? content.map((raw) => {
490
+ const item = getObject(raw)
491
+ if (typeof item.data === 'object' && item.data !== null && item.attrs === undefined) {
492
+ return h(getString(item.type), item.data as Record<string, unknown>)
493
+ }
494
+ return raw
495
+ })
496
+ : content
497
+ const elements = typeof normalized === 'string'
498
+ ? h.parse(normalized)
499
+ : h.toElementArray(normalized)
451
500
  return JSON.parse(JSON.stringify(elements)) as unknown[]
452
501
  } catch {
453
502
  return []
package/src/types.ts CHANGED
@@ -26,6 +26,8 @@ export interface MessageRecord {
26
26
  content?: string
27
27
  elements?: unknown[]
28
28
  raw?: unknown
29
+ revoked?: boolean
30
+ revokedAt?: number
29
31
  }
30
32
 
31
33
  export type SelfMessageChannelType = 'group' | 'user'
@@ -49,6 +51,8 @@ export interface SelfMessageRecord {
49
51
  source: SelfMessageSource
50
52
  kind: string
51
53
  fingerprint?: string
54
+ revoked?: boolean
55
+ revokedAt?: number
52
56
  }
53
57
 
54
58
  export interface SelfMessagePayload {
@@ -68,6 +72,8 @@ export interface SelfMessagePayload {
68
72
  sequence?: number
69
73
  source?: SelfMessageSource
70
74
  kind?: string
75
+ revoked?: boolean
76
+ revokedAt?: number
71
77
  }
72
78
 
73
79
  export interface HistoryQuery {