koishi-plugin-chat-patch 5.3.0 → 5.5.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.
Files changed (77) hide show
  1. package/client/web/dist/assets/Chat-Clqxn7CB.js +28 -0
  2. package/client/web/dist/assets/{Chat-CTdyeaX2.css → Chat-DTEsebnY.css} +1 -1
  3. package/client/web/dist/assets/{MsgBody-DDacjWJ9.js → MsgBody-C0TKsXhC.js} +1 -1
  4. package/client/web/dist/assets/{MsgBody.vue_vue_type_script_setup_true_lang-B1dWyCB0.js → MsgBody.vue_vue_type_script_setup_true_lang-CCdBwlN_.js} +2 -2
  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-BlLut5xn.js → index-BA37EQGa.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 +7 -4
  28. package/client/web/src/components/BulletinBody.vue +1 -1
  29. package/client/web/src/components/DepPan.vue +1 -1
  30. package/client/web/src/components/FriendBody.vue +1 -0
  31. package/client/web/src/components/MsgBody.vue +1 -3
  32. package/client/web/src/components/ThemeColorPickerPan.vue +1 -1
  33. package/client/web/src/components/TinySessionBody.vue +1 -0
  34. package/client/web/src/components/UmamiInfoPan.vue +1 -1
  35. package/client/web/src/components/UpdatePan.vue +2 -2
  36. package/client/web/src/components/WelPan.vue +1 -1
  37. package/client/web/src/components/tooltip/UserInfoTooltip.vue +1 -1
  38. package/client/web/src/function/connect.ts +279 -86
  39. package/client/web/src/function/elements/information.ts +1 -0
  40. package/client/web/src/function/msg.ts +42 -23
  41. package/client/web/src/function/satori-model.ts +35 -13
  42. package/client/web/src/function/satori.ts +33 -2
  43. package/client/web/src/function/utils/appUtil.ts +3 -11
  44. package/client/web/src/function/utils/avatarUtil.ts +18 -3
  45. package/client/web/src/function/utils/localHistoryUtil.ts +36 -12
  46. package/client/web/src/function/utils/msgUtil.ts +32 -21
  47. package/client/web/src/function/utils/sessionUtil.ts +20 -18
  48. package/client/web/src/main.ts +5 -0
  49. package/client/web/src/pages/Chat.vue +50 -47
  50. package/client/web/src/pages/Friends.vue +28 -24
  51. package/client/web/src/pages/Info.vue +5 -5
  52. package/client/web/src/pages/Messages.vue +57 -36
  53. package/client/web/src/pages/Qzone.vue +2 -2
  54. package/client/web/src/pages/chat-view/ChatTerminal.vue +4 -1
  55. package/client/web/src/pages/chat-view/SystemNotice.vue +3 -3
  56. package/client/web/src/pages/options/OptAccount.vue +6 -4
  57. package/client/web/src/pages/options/OptDev.vue +45 -2
  58. package/lib/database.d.ts +58 -0
  59. package/lib/index.js +234 -82
  60. package/lib/media.d.ts +24 -0
  61. package/lib/recorder.d.ts +21 -0
  62. package/lib/self-message.d.ts +27 -0
  63. package/lib/types.d.ts +121 -0
  64. package/lib/web.d.ts +7 -0
  65. package/package.json +1 -1
  66. package/src/bootstrap.ts +1 -0
  67. package/src/cache.ts +1 -5
  68. package/src/config.ts +1 -1
  69. package/src/database.ts +52 -8
  70. package/src/index.ts +4 -4
  71. package/src/media.ts +97 -0
  72. package/src/recorder.ts +35 -26
  73. package/src/self-message.ts +68 -19
  74. package/src/types.ts +10 -0
  75. package/src/web.ts +57 -48
  76. package/client/web/dist/assets/Chat-B448YOeN.js +0 -28
  77. package/client/web/dist/assets/index-fW_zcub3.css +0 -1
@@ -0,0 +1,27 @@
1
+ import { Context } from 'koishi';
2
+ import { Config } from './config';
3
+ import { ChatDatabase } from './database';
4
+ import { MediaManager } from './media';
5
+ import { PluginLogger } from './logger';
6
+ export declare class SelfMessageRecorder {
7
+ private ctx;
8
+ private config;
9
+ private database;
10
+ private media;
11
+ private logger;
12
+ private wrapped;
13
+ private sequence;
14
+ constructor(ctx: Context, config: Config, database: ChatDatabase, media: MediaManager, logger: PluginLogger);
15
+ start(): void;
16
+ dispose(): void;
17
+ private isBlocked;
18
+ private wrapBot;
19
+ private unwrapBot;
20
+ private prepareOptions;
21
+ private recordFromWrapper;
22
+ private markSelfMessageRevoked;
23
+ private recordSendEvent;
24
+ private resolveI18nElement;
25
+ private buildRecord;
26
+ private normalizeElements;
27
+ }
package/lib/types.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ import type { Awaitable } from 'koishi';
2
+ export interface SatoriBootstrap {
3
+ endpoint: string;
4
+ token: string;
5
+ basePath: string;
6
+ blockedPlatforms: Array<{
7
+ platformName: string;
8
+ exactMatch: boolean;
9
+ }>;
10
+ }
11
+ export interface MessageRecord {
12
+ id?: string;
13
+ sequence?: number;
14
+ type: string;
15
+ platform: string;
16
+ selfId: string;
17
+ channelId?: string;
18
+ guildId?: string;
19
+ userId?: string;
20
+ timestamp: number;
21
+ timestampMs?: number;
22
+ receivedAt?: number;
23
+ content?: string;
24
+ elements?: unknown[];
25
+ raw?: unknown;
26
+ revoked?: boolean;
27
+ revokedAt?: number;
28
+ }
29
+ export type SelfMessageChannelType = 'group' | 'user';
30
+ export type SelfMessageSource = 'webui' | 'bot' | 'plugin';
31
+ export interface SelfMessageRecord {
32
+ id: string;
33
+ platform: string;
34
+ selfId: string;
35
+ channelId: string;
36
+ guildId?: string;
37
+ channelType?: SelfMessageChannelType;
38
+ messageId?: string;
39
+ content?: string;
40
+ elements?: unknown[];
41
+ message?: unknown[];
42
+ forwardId?: string;
43
+ forwardContent?: unknown[];
44
+ sentAt: number;
45
+ sequence: number;
46
+ source: SelfMessageSource;
47
+ kind: string;
48
+ fingerprint?: string;
49
+ revoked?: boolean;
50
+ revokedAt?: number;
51
+ }
52
+ export interface SelfMessagePayload {
53
+ id?: string;
54
+ platform: string;
55
+ selfId: string;
56
+ channelId: string;
57
+ guildId?: string;
58
+ channelType?: SelfMessageChannelType;
59
+ messageId?: string;
60
+ content?: string;
61
+ elements?: unknown[];
62
+ message?: unknown[];
63
+ forwardId?: string;
64
+ forwardContent?: unknown[];
65
+ sentAt?: number;
66
+ sequence?: number;
67
+ source?: SelfMessageSource;
68
+ kind?: string;
69
+ revoked?: boolean;
70
+ revokedAt?: number;
71
+ }
72
+ export interface HistoryQuery {
73
+ platform: string;
74
+ selfId: string;
75
+ channelId: string;
76
+ limit?: number;
77
+ }
78
+ export interface HistoryResult {
79
+ messages: MessageRecord[];
80
+ }
81
+ export interface PinnedState {
82
+ bots: string[];
83
+ channels: string[];
84
+ }
85
+ export interface PluginConfigPayload {
86
+ basePath: string;
87
+ maxMessagesPerChannel: number;
88
+ historyPageSize: number;
89
+ maxMediaFiles: number;
90
+ blockedPlatforms: Array<{
91
+ platformName: string;
92
+ exactMatch: boolean;
93
+ }>;
94
+ loggerinfo: boolean;
95
+ }
96
+ export interface ContactCacheItem {
97
+ id: string;
98
+ name: string;
99
+ avatar?: string;
100
+ channelId?: string;
101
+ guildId?: string;
102
+ raw?: unknown;
103
+ }
104
+ export interface ContactCacheQuery {
105
+ platform: string;
106
+ selfId: string;
107
+ type: string;
108
+ contacts?: ContactCacheItem[];
109
+ append?: boolean;
110
+ }
111
+ export interface ContactCacheResult {
112
+ contacts?: ContactCacheItem[];
113
+ }
114
+ declare module '@koishijs/console' {
115
+ interface Events {
116
+ 'chat-patch/bootstrap'(): SatoriBootstrap;
117
+ 'chat-patch/history'(query: HistoryQuery): Awaitable<HistoryResult>;
118
+ 'chat-patch/config'(): PluginConfigPayload;
119
+ 'chat-patch/contact-cache'(query: ContactCacheQuery): Awaitable<ContactCacheResult>;
120
+ }
121
+ }
package/lib/web.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { Context } from 'koishi';
2
+ import { Config } from './config';
3
+ import { ContactCacheService } from './cache';
4
+ import { ChatDatabase } from './database';
5
+ import { MediaManager } from './media';
6
+ import { PluginLogger } from './logger';
7
+ export declare function registerWeb(ctx: Context, config: Config, database: ChatDatabase, contactCache: ContactCacheService, media: MediaManager, logger: PluginLogger): void;
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.0",
4
+ "version": "5.5.0",
5
5
  "scripts": {
6
6
  "build:web": "npm --prefix client/web run build",
7
7
  "prepack": "npm run build:web",
package/src/bootstrap.ts CHANGED
@@ -31,6 +31,7 @@ export function registerBootstrap(
31
31
  endpoint: resolveEndpoint(ctx, config),
32
32
  token: ctx.satori?.server?.config?.token ?? '',
33
33
  basePath: config.basePath,
34
+ blockedPlatforms: config.blockedPlatforms ?? [],
34
35
  }
35
36
  }, { authority: 4 })
36
37
 
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/config.ts CHANGED
@@ -25,7 +25,7 @@ export const Config: Schema<Config> = Schema.intersect([
25
25
  blockedPlatforms: Schema.array(Schema.object({
26
26
  platformName: Schema.string(),
27
27
  exactMatch: Schema.boolean().default(true),
28
- })).role('table').description('不记录历史的平台').default([
28
+ })).role('table').description('屏蔽消息接收的平台').default([
29
29
  { platformName: 'qq', exactMatch: true },
30
30
  { platformName: 'qqguild', exactMatch: true },
31
31
  { platformName: 'sandbox', exactMatch: false },
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
  }
@@ -41,8 +45,8 @@ export class Recorder {
41
45
 
42
46
  private async handleSession(session: Session) {
43
47
  const platform = session.platform || 'unknown'
44
- if (this.isBlocked(platform)) return
45
48
  if (!MESSAGE_TYPES.has(session.type)) return
49
+ if (this.isBlocked(platform)) return
46
50
 
47
51
  const event = session.toJSON()
48
52
  const message = event.message
@@ -65,15 +69,35 @@ export class Recorder {
65
69
 
66
70
  try {
67
71
  await this.database.appendMessage(record)
68
- void this.cacheMessageContacts(event).catch((error) => {
69
- this.logger.warn('缓存消息联系人失败:', error)
70
- })
71
- void this.cacheMessageMedia(event).catch((error) => {
72
- this.logger.warn('异步缓存消息媒体失败:', error)
73
- })
74
72
  } catch (error) {
75
73
  this.logger.warn('写入历史消息失败:', error)
76
74
  }
75
+ void this.cacheMessageContacts(event).catch((error) => {
76
+ this.logger.warn('缓存消息联系人失败:', error)
77
+ })
78
+ void this.cacheMessageMedia(event).catch((error) => {
79
+ this.logger.warn('异步缓存消息媒体失败:', error)
80
+ })
81
+ }
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)
77
101
  }
78
102
 
79
103
  private async cacheMessageContacts(event: ReturnType<Session['toJSON']>) {
@@ -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
  }