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.
- package/client/web/dist/assets/Chat-B5Jdrz_2.js +28 -0
- package/client/web/dist/assets/{Chat-C-rmzMIw.css → Chat-DTEsebnY.css} +1 -1
- package/client/web/dist/assets/{MsgBody-h4C1nnBQ.js → MsgBody-tPnKSZOs.js} +1 -1
- 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
- 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-CdTmWcO4.js → index-CSGfOsfr.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 +5 -3
- package/client/web/src/components/DepPan.vue +1 -1
- package/client/web/src/components/MsgBody.vue +0 -3
- package/client/web/src/components/ThemeColorPickerPan.vue +1 -1
- package/client/web/src/components/UmamiInfoPan.vue +1 -1
- package/client/web/src/components/WelPan.vue +1 -1
- package/client/web/src/function/connect.ts +232 -72
- package/client/web/src/function/elements/information.ts +1 -0
- package/client/web/src/function/msg.ts +43 -24
- package/client/web/src/function/satori-model.ts +47 -15
- package/client/web/src/function/satori.ts +2 -1
- package/client/web/src/function/utils/appUtil.ts +3 -11
- 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/pages/Chat.vue +47 -43
- package/client/web/src/pages/Friends.vue +5 -7
- package/client/web/src/pages/Info.vue +2 -2
- package/client/web/src/pages/Messages.vue +37 -23
- package/client/web/src/pages/chat-view/ChatTerminal.vue +4 -1
- package/client/web/src/pages/options/OptDev.vue +45 -2
- package/lib/database.d.ts +58 -0
- package/lib/index.js +220 -68
- 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 +6 -0
- package/lib/web.d.ts +7 -0
- package/package.json +1 -1
- package/src/cache.ts +1 -5
- package/src/database.ts +52 -8
- package/src/index.ts +4 -4
- package/src/media.ts +97 -0
- package/src/recorder.ts +28 -19
- package/src/self-message.ts +68 -19
- package/src/types.ts +6 -0
- package/src/web.ts +49 -39
- package/client/web/dist/assets/Chat-Bo5JdcNg.js +0 -28
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|
package/src/self-message.ts
CHANGED
|
@@ -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
|
|
55
|
-
|
|
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) =>
|
|
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 ||
|
|
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 =
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
449
|
-
?
|
|
450
|
-
:
|
|
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 {
|