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.
- package/client/web/dist/assets/Chat-Clqxn7CB.js +28 -0
- package/client/web/dist/assets/{Chat-CTdyeaX2.css → Chat-DTEsebnY.css} +1 -1
- package/client/web/dist/assets/{MsgBody-DDacjWJ9.js → MsgBody-C0TKsXhC.js} +1 -1
- 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
- package/client/web/dist/assets/{append_darwin-CqBNU62J.css → append_darwin-Cql-dV6d.css} +1 -1
- package/client/web/dist/assets/{append_full_vibrancy-j0D2LM9m.css → append_full_vibrancy-ih3loy1O.css} +1 -1
- package/client/web/dist/assets/{append_linux-uhxeBqeC.css → append_linux-B3LvR8KB.css} +1 -1
- package/client/web/dist/assets/{append_new-407Lo5_V.css → append_new-DfhN_NX9.css} +1 -1
- package/client/web/dist/assets/{append_vibrancy-6FHctkOY.css → append_vibrancy-D3pVsmAa.css} +1 -1
- package/client/web/dist/assets/{append_win32-srmD-78y.css → append_win32-Dh9JlmlE.css} +1 -1
- package/client/web/dist/assets/index-B71pPwae.css +1 -0
- package/client/web/dist/assets/{index-BlLut5xn.js → index-BA37EQGa.js} +97 -76
- package/client/web/dist/index.html +2 -2
- package/client/web/src/App.vue +2 -2
- package/client/web/src/assets/css/append/append_darwin.css +2 -2
- package/client/web/src/assets/css/append/append_full_vibrancy.css +2 -2
- package/client/web/src/assets/css/append/append_linux.css +2 -2
- package/client/web/src/assets/css/append/append_new.css +2 -2
- package/client/web/src/assets/css/append/append_vibrancy.css +1 -1
- package/client/web/src/assets/css/append/append_win32.css +2 -2
- package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +1 -1
- package/client/web/src/assets/css/chat.css +4 -3
- package/client/web/src/assets/css/msg.css +7 -3
- package/client/web/src/assets/css/options.css +2 -2
- package/client/web/src/assets/css/view.css +14 -3
- package/client/web/src/assets/l10n/zh-CN.po +21 -0
- package/client/web/src/components/AboutPan.vue +7 -4
- package/client/web/src/components/BulletinBody.vue +1 -1
- package/client/web/src/components/DepPan.vue +1 -1
- package/client/web/src/components/FriendBody.vue +1 -0
- package/client/web/src/components/MsgBody.vue +1 -3
- package/client/web/src/components/ThemeColorPickerPan.vue +1 -1
- package/client/web/src/components/TinySessionBody.vue +1 -0
- package/client/web/src/components/UmamiInfoPan.vue +1 -1
- package/client/web/src/components/UpdatePan.vue +2 -2
- package/client/web/src/components/WelPan.vue +1 -1
- package/client/web/src/components/tooltip/UserInfoTooltip.vue +1 -1
- package/client/web/src/function/connect.ts +279 -86
- package/client/web/src/function/elements/information.ts +1 -0
- package/client/web/src/function/msg.ts +42 -23
- package/client/web/src/function/satori-model.ts +35 -13
- package/client/web/src/function/satori.ts +33 -2
- package/client/web/src/function/utils/appUtil.ts +3 -11
- package/client/web/src/function/utils/avatarUtil.ts +18 -3
- package/client/web/src/function/utils/localHistoryUtil.ts +36 -12
- package/client/web/src/function/utils/msgUtil.ts +32 -21
- package/client/web/src/function/utils/sessionUtil.ts +20 -18
- package/client/web/src/main.ts +5 -0
- package/client/web/src/pages/Chat.vue +50 -47
- package/client/web/src/pages/Friends.vue +28 -24
- package/client/web/src/pages/Info.vue +5 -5
- package/client/web/src/pages/Messages.vue +57 -36
- package/client/web/src/pages/Qzone.vue +2 -2
- package/client/web/src/pages/chat-view/ChatTerminal.vue +4 -1
- package/client/web/src/pages/chat-view/SystemNotice.vue +3 -3
- package/client/web/src/pages/options/OptAccount.vue +6 -4
- package/client/web/src/pages/options/OptDev.vue +45 -2
- package/lib/database.d.ts +58 -0
- package/lib/index.js +234 -82
- package/lib/media.d.ts +24 -0
- package/lib/recorder.d.ts +21 -0
- package/lib/self-message.d.ts +27 -0
- package/lib/types.d.ts +121 -0
- package/lib/web.d.ts +7 -0
- package/package.json +1 -1
- package/src/bootstrap.ts +1 -0
- package/src/cache.ts +1 -5
- package/src/config.ts +1 -1
- package/src/database.ts +52 -8
- package/src/index.ts +4 -4
- package/src/media.ts +97 -0
- package/src/recorder.ts +35 -26
- package/src/self-message.ts +68 -19
- package/src/types.ts +10 -0
- package/src/web.ts +57 -48
- package/client/web/dist/assets/Chat-B448YOeN.js +0 -28
- 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.
|
|
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
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
|
-
|
|
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('
|
|
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
|
-
|
|
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(/&/g, '&')
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, "'")
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/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
|
-
|
|
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
|
-
|
|
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
|
}
|