dsh-qqchat 0.2.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +276 -0
  3. package/README.md +211 -0
  4. package/client-src/plugin.cts +15 -0
  5. package/client-src/settings.cts +216 -0
  6. package/client-src/shared.cts +104 -0
  7. package/client-src/workspace.cts +164 -0
  8. package/cordis.patch.yml +7 -0
  9. package/docs/ARCHITECTURE.en.md +346 -0
  10. package/docs/ARCHITECTURE.md +411 -0
  11. package/docs/DEVELOPMENT.md +69 -0
  12. package/docs/MEDIA_AND_QUOTES.md +644 -0
  13. package/docs/MEMORY.md +99 -0
  14. package/docs/MEMORY_CONTEXT.md +254 -0
  15. package/docs/QUOTES.md +355 -0
  16. package/docs/README.md +22 -0
  17. package/docs/SECURITY.md +62 -0
  18. package/docs/STREAMING.md +347 -0
  19. package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
  20. package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
  21. package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
  22. package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
  23. package/docs/devlog.md +24 -0
  24. package/lib/client.js +433 -0
  25. package/lib/commands/dispatch.d.ts +16 -0
  26. package/lib/commands/dispatch.js +48 -0
  27. package/lib/config.d.ts +3 -0
  28. package/lib/config.js +63 -0
  29. package/lib/gateway/api.d.ts +30 -0
  30. package/lib/gateway/api.js +202 -0
  31. package/lib/gateway/auth.d.ts +35 -0
  32. package/lib/gateway/auth.js +80 -0
  33. package/lib/gateway/crypto.d.ts +2 -0
  34. package/lib/gateway/crypto.js +17 -0
  35. package/lib/gateway/gateway.d.ts +32 -0
  36. package/lib/gateway/gateway.js +219 -0
  37. package/lib/gateway/normalize.d.ts +6 -0
  38. package/lib/gateway/normalize.js +248 -0
  39. package/lib/index.d.ts +14 -0
  40. package/lib/index.js +35 -0
  41. package/lib/media/image-tool.d.ts +4 -0
  42. package/lib/media/image-tool.js +62 -0
  43. package/lib/media/media-tools.d.ts +3 -0
  44. package/lib/media/media-tools.js +68 -0
  45. package/lib/media/store.d.ts +14 -0
  46. package/lib/media/store.js +176 -0
  47. package/lib/session/agent-bridge.d.ts +56 -0
  48. package/lib/session/agent-bridge.js +613 -0
  49. package/lib/session/memory-snapshot.d.ts +23 -0
  50. package/lib/session/memory-snapshot.js +27 -0
  51. package/lib/session/runtime.d.ts +41 -0
  52. package/lib/session/runtime.js +395 -0
  53. package/lib/shared/augmentations.d.ts +40 -0
  54. package/lib/shared/augmentations.js +1 -0
  55. package/lib/shared/logging.d.ts +14 -0
  56. package/lib/shared/logging.js +41 -0
  57. package/lib/storage/db.d.ts +82 -0
  58. package/lib/storage/db.js +669 -0
  59. package/lib/storage/memory.d.ts +50 -0
  60. package/lib/storage/memory.js +566 -0
  61. package/lib/transport/rpc.d.ts +18 -0
  62. package/lib/transport/rpc.js +318 -0
  63. package/lib/types.d.ts +416 -0
  64. package/lib/types.js +1 -0
  65. package/package.json +115 -0
  66. package/scripts/wrap-client.mjs +20 -0
  67. package/src/commands/dispatch.ts +75 -0
  68. package/src/config.ts +70 -0
  69. package/src/gateway/api.ts +260 -0
  70. package/src/gateway/auth.ts +99 -0
  71. package/src/gateway/crypto.ts +16 -0
  72. package/src/gateway/gateway.ts +207 -0
  73. package/src/gateway/normalize.ts +236 -0
  74. package/src/index.ts +46 -0
  75. package/src/media/image-tool.ts +62 -0
  76. package/src/media/media-tools.ts +66 -0
  77. package/src/media/store.ts +157 -0
  78. package/src/session/agent-bridge.ts +624 -0
  79. package/src/session/memory-snapshot.ts +41 -0
  80. package/src/session/runtime.ts +368 -0
  81. package/src/shared/augmentations.ts +45 -0
  82. package/src/shared/logging.ts +36 -0
  83. package/src/storage/db.ts +760 -0
  84. package/src/storage/memory.ts +581 -0
  85. package/src/transport/rpc.ts +316 -0
  86. package/src/types.ts +456 -0
  87. package/tsconfig.build.json +12 -0
  88. package/tsconfig.client.json +14 -0
  89. package/tsconfig.json +23 -0
@@ -0,0 +1,368 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import type { DshQQBridge } from './agent-bridge.js'
4
+ import { defaultRuntimeSettings } from '../config.js'
5
+ import type { QQChatDatabase } from '../storage/db.js'
6
+ import type { QQChatLogger } from '../shared/logging.js'
7
+ import type { MemoryEngine } from '../storage/memory.js'
8
+ import type { QQApiClient } from '../gateway/api.js'
9
+ import type { QQBindService } from '../gateway/auth.js'
10
+ import { QQGateway } from '../gateway/gateway.js'
11
+ import { renderQQMentionNames } from '../gateway/normalize.js'
12
+ import { QQMediaStore } from '../media/store.js'
13
+ import type {
14
+ AccountRow, ChatTargetRow, ChatType, GroupReceiveMode, GroupRow, MemberRow, QQAttachmentInput, QQChatConfig,
15
+ QQChatDisplayEvent, QQChatRuntimeSettings, QQChatRuntimeSettingsPatch, QQNormalizedMessage, QQQuoteInput, ReplyFormat, StoredAttachmentSummary,
16
+ } from '../types.js'
17
+
18
+ const QUOTE_INDEX_TTL_MS = 7 * 24 * 60 * 60 * 1000
19
+
20
+ export class QQChatRuntime {
21
+ readonly gateways = new Map<number, QQGateway>()
22
+ private outboxTimer?: ReturnType<typeof setInterval>
23
+ private mediaCleanupTimer?: ReturnType<typeof setInterval>
24
+
25
+ constructor(
26
+ readonly ctx: Context, readonly db: QQChatDatabase, readonly api: QQApiClient,
27
+ readonly auth: QQBindService, readonly memory: MemoryEngine, readonly bridge: DshQQBridge,
28
+ readonly config: QQChatConfig, readonly logger: QQChatLogger, readonly media: QQMediaStore,
29
+ ) {}
30
+
31
+ async start(): Promise<void> {
32
+ for (const account of this.db.enabledAccounts()) this.startGateway(account)
33
+ this.outboxTimer = setInterval(() => void this.flushOutbox(), 3000)
34
+ this.outboxTimer.unref?.()
35
+ void this.media.cleanup()
36
+ this.mediaCleanupTimer = setInterval(() => void this.media.cleanup(), 60 * 60 * 1000)
37
+ this.mediaCleanupTimer.unref?.()
38
+ this.logger.info?.('[dsh-qqchat] runtime started')
39
+ }
40
+
41
+ async stop(): Promise<void> {
42
+ if (this.outboxTimer) clearInterval(this.outboxTimer)
43
+ this.outboxTimer = undefined
44
+ if (this.mediaCleanupTimer) clearInterval(this.mediaCleanupTimer)
45
+ this.mediaCleanupTimer = undefined
46
+ await Promise.all([...this.gateways.values()].map(gateway => gateway.stop().catch(() => undefined)))
47
+ this.gateways.clear()
48
+ await this.bridge.dispose()
49
+ this.memory.dispose()
50
+ this.db.close()
51
+ }
52
+
53
+ settings(): QQChatRuntimeSettings {
54
+ const settings = this.db.runtimeSettings(defaultRuntimeSettings(this.config))
55
+ if (settings.directReplyFormat === 'compat') settings.directStreamingEnabled = false
56
+ return settings
57
+ }
58
+
59
+ updateSettings(patch: QQChatRuntimeSettingsPatch): QQChatRuntimeSettings {
60
+ if (patch.memoryEnabled !== undefined) this.db.setSetting('memoryEnabled', Boolean(patch.memoryEnabled))
61
+ if (patch.groupReceiveMode !== undefined) {
62
+ if (!isGroupReceiveMode(patch.groupReceiveMode)) throw new Error('无效的群聊接收模式')
63
+ this.db.setSetting('groupReceiveMode', patch.groupReceiveMode)
64
+ }
65
+ if (patch.groupReplyFormat !== undefined) {
66
+ if (!isReplyFormat(patch.groupReplyFormat)) throw new Error('无效的群聊消息兼容格式')
67
+ this.db.setSetting('groupReplyFormat', patch.groupReplyFormat)
68
+ }
69
+ if (patch.directReplyFormat !== undefined) {
70
+ if (!isReplyFormat(patch.directReplyFormat)) throw new Error('无效的私聊消息兼容格式')
71
+ this.db.setSetting('directReplyFormat', patch.directReplyFormat)
72
+ if (patch.directReplyFormat === 'compat') this.db.setSetting('directStreamingEnabled', false)
73
+ }
74
+ if (patch.directStreamingEnabled !== undefined) this.db.setSetting('directStreamingEnabled', Boolean(patch.directStreamingEnabled))
75
+ if (patch.groupMembersCanUseTools !== undefined) this.db.setSetting('groupMembersCanUseTools', Boolean(patch.groupMembersCanUseTools))
76
+ if (patch.groupMembersCanReceiveMedia !== undefined) this.db.setSetting('groupMembersCanReceiveMedia', Boolean(patch.groupMembersCanReceiveMedia))
77
+ if (patch.groupMembersCanReadMedia !== undefined) this.db.setSetting('groupMembersCanReadMedia', Boolean(patch.groupMembersCanReadMedia))
78
+ if (patch.ownerUserId !== undefined) this.db.setSetting('ownerUserId', String(patch.ownerUserId).trim())
79
+ const next = this.settings()
80
+ this.logger.info?.('[dsh-qqchat] settings updated', next)
81
+ return next
82
+ }
83
+
84
+ listChats(): ChatTargetRow[] {
85
+ const groups: ChatTargetRow[] = this.db.listGroups().map(row => ({
86
+ chatType: 'group', rowId: Number(row.id), accountId: Number(row.account_id), platformId: row.platform_group_id,
87
+ displayName: row.name || `QQ群 ${shortId(row.platform_group_id)}`, dshSessionId: row.dsh_session_id || null,
88
+ lastMessageAt: row.last_message_at ? Number(row.last_message_at) : null, messageCount: Number(row.message_count || 0),
89
+ }))
90
+ const directs: ChatTargetRow[] = this.db.listDirectChats().map(row => ({
91
+ chatType: 'c2c', rowId: Number(row.id), accountId: Number(row.account_id), platformId: row.platform_user_id,
92
+ displayName: row.display_name || `QQ 用户 ${shortId(row.platform_user_id)}`, dshSessionId: row.dsh_session_id || null,
93
+ lastMessageAt: row.last_message_at ? Number(row.last_message_at) : null, messageCount: Number(row.message_count || 0),
94
+ }))
95
+ return [...groups, ...directs].sort((a, b) => (b.lastMessageAt || 0) - (a.lastMessageAt || 0))
96
+ }
97
+
98
+ async ensureChatSession(chatType: ChatType, rowId: number): Promise<string> {
99
+ const row = this.chatRow(chatType, rowId)
100
+ if (!row) throw new Error(chatType === 'group' ? '群不存在' : '私聊用户不存在')
101
+ return this.bridge.ensureChatSession(chatType, row)
102
+ }
103
+
104
+ async sendActive(chatType: ChatType, rowId: number, content: string): Promise<string> {
105
+ const text = content.trim()
106
+ if (!text) throw new Error('消息不能为空')
107
+ const row = this.chatRow(chatType, rowId)
108
+ if (!row) throw new Error(chatType === 'group' ? '群不存在' : '私聊用户不存在')
109
+ const account = this.db.accountById(Number(row.account_id))
110
+ if (!account) throw new Error('QQ 账号不存在')
111
+ const targetId = chatType === 'group' ? (row as GroupRow).platform_group_id : (row as MemberRow).platform_user_id
112
+ const settings = this.settings()
113
+ await this.api.sendText(account, targetId, text, { group: chatType === 'group', messageId: null, format: chatType === 'group' ? settings.groupReplyFormat : settings.directReplyFormat })
114
+ const messageDbId = this.db.insertMessage({
115
+ accountId: Number(account.id), chatType, groupId: chatType === 'group' ? Number(row.id) : undefined,
116
+ memberId: chatType === 'c2c' ? Number(row.id) : undefined, direction: 'outbound', content: text,
117
+ })
118
+ const sessionId = await this.bridge.recordTranscript({
119
+ messageId: `db:${messageDbId}`, chatType, chatId: targetId, direction: 'outbound', senderId: 'OWNER', senderName: 'Owner',
120
+ content: text, quotedText: '', mentioned: false, createdAt: Date.now(),
121
+ }, row, true)
122
+ if (this.settings().memoryEnabled) {
123
+ if (chatType === 'group') this.memory.schedule(Number(row.id))
124
+ else this.memory.scheduleMember(Number(row.id))
125
+ }
126
+ this.logger.info?.(`[dsh-qqchat] active ${chatType} message sent to ${shortId(targetId)}`)
127
+ if (!sessionId) throw new Error('未能建立 QQ Chat DSH session')
128
+ return sessionId
129
+ }
130
+
131
+ async readAttachment(sessionId: string, attachmentId: string): Promise<Record<string, unknown>> {
132
+ const attachment = this.db.attachmentForSession(sessionId, attachmentId)
133
+ if (!attachment) throw new Error('附件不存在或当前 Session 无权访问')
134
+ if (attachment.kind !== 'image' || !attachment.localPath) {
135
+ return { id: attachment.id, kind: attachment.kind, filename: attachment.filename, sizeBytes: attachment.sizeBytes, imageRef: attachment.imageRef || null }
136
+ }
137
+ if (attachment.sizeBytes > 8 * 1024 * 1024) throw new Error('图片超过 Web 预览大小限制')
138
+ const bytes = await readFile(attachment.localPath)
139
+ const mime = attachment.imageRef?.mediaType || attachment.contentType || 'image/png'
140
+ return {
141
+ id: attachment.id, kind: attachment.kind, filename: attachment.filename, sizeBytes: attachment.sizeBytes,
142
+ imageRef: attachment.imageRef || null, dataUrl: `data:${mime};base64,${bytes.toString('base64')}`,
143
+ }
144
+ }
145
+
146
+ startGateway(account: AccountRow | undefined): void {
147
+ if (!account) return
148
+ const id = Number(account.id)
149
+ if (this.gateways.has(id)) return
150
+ const gateway = new QQGateway(account, this.db, this.api, message => this.onIncoming(message), this.logger)
151
+ this.gateways.set(id, gateway)
152
+ gateway.start()
153
+ }
154
+
155
+ async restartGateway(accountId: number): Promise<void> {
156
+ const existing = this.gateways.get(Number(accountId))
157
+ if (existing) await existing.stop()
158
+ this.gateways.delete(Number(accountId))
159
+ const account = this.db.accountById(Number(accountId))
160
+ if (account?.enabled) this.startGateway(account)
161
+ }
162
+
163
+ private async onIncoming(message: QQNormalizedMessage): Promise<void> {
164
+ const account = this.db.accountById(message.accountId)
165
+ if (!account || !account.enabled) return
166
+ const member = this.db.upsertMember(message.accountId, message.senderId, message.senderName)
167
+ let group: GroupRow | undefined
168
+ let shouldReply = true
169
+ const settings = this.settings()
170
+ if (message.chatType === 'group') {
171
+ if (!message.groupOpenid) return
172
+ const mode = settings.groupReceiveMode
173
+ group = this.db.upsertGroup(message.accountId, message.groupOpenid, { enabled: true, readEnabled: true })
174
+ this.db.touchGroupMember(Number(group.id), Number(member.id), message.senderName)
175
+ if (group.enabled !== 1 || group.read_enabled !== 1) return
176
+ shouldReply = shouldReplyToGroup(mode, group, message.mentioned)
177
+ }
178
+ mergeIndexedQuote(message, this.db.quoteIndex(message.accountId, message.chatType, message.chatId, message.refMsgIdx || ''))
179
+ message.text = renderQQMentionNames(message.text, message.raw, id => {
180
+ const mentionedMember = this.db.memberByPlatform(message.accountId, id)
181
+ return mentionedMember?.display_name || undefined
182
+ })
183
+
184
+ const isOwner = message.chatType === 'c2c'
185
+ || (settings.ownerUserId !== '' && message.senderId === settings.ownerUserId)
186
+ const acceptGroupMedia = message.chatType !== 'group' || isOwner || settings.groupMembersCanReceiveMedia
187
+ const ownAttachments = acceptGroupMedia && message.attachments.length
188
+ ? await this.media.ingest(message.accountId, message.messageId, message.attachments)
189
+ : []
190
+ const quotedAttachments = acceptGroupMedia && message.quote?.attachments?.length
191
+ ? await this.media.ingest(message.accountId, message.quote.messageId || message.messageId, message.quote.attachments)
192
+ : []
193
+ const storedAttachments = dedupeAttachments([...ownAttachments, ...quotedAttachments])
194
+ const safeQuote = message.quote ? sanitizeQuote(message.quote, acceptGroupMedia, storedAttachments) : undefined
195
+
196
+ const messageDbId = this.db.insertMessage({
197
+ accountId: message.accountId, platformMessageId: message.messageId, chatType: message.chatType, groupId: group?.id,
198
+ memberId: member.id, direction: 'inbound', content: message.text, quotedText: message.quotedText,
199
+ attachments: storedAttachments, quote: safeQuote,
200
+ mentioned: message.mentioned, raw: message.raw,
201
+ })
202
+ const msgIdx = message.msgIdx || message.messageId
203
+ if (msgIdx) this.db.saveQuoteIndex({
204
+ accountId: message.accountId, chatType: message.chatType, chatId: message.chatId,
205
+ msgIdx, platformMessageId: message.messageId || undefined,
206
+ senderId: message.senderId, senderName: message.senderName, content: message.text,
207
+ attachments: indexAttachments(message.attachments, storedAttachments), createdAt: Date.now(), expiresAt: Date.now() + QUOTE_INDEX_TTL_MS,
208
+ })
209
+ const row = message.chatType === 'group' ? group! : member
210
+ const displayEvent: QQChatDisplayEvent = {
211
+ messageId: `db:${messageDbId}`, chatType: message.chatType,
212
+ chatId: message.chatType === 'group' ? message.groupOpenid! : message.senderId,
213
+ direction: 'inbound', senderId: message.senderId,
214
+ senderName: message.senderName || member.display_name || (isOwner ? 'Owner' : 'QQ 用户'),
215
+ isOwner,
216
+ content: transcriptContent(message.text, ownAttachments),
217
+ quotedText: message.quotedText, mentioned: message.mentioned, createdAt: Date.now(), attachments: publicAttachments(ownAttachments), quote: safeQuote,
218
+ }
219
+ if (settings.memoryEnabled) {
220
+ if (group) this.memory.schedule(Number(group.id))
221
+ else this.memory.scheduleMember(Number(member.id))
222
+ }
223
+
224
+ if (!shouldReply) {
225
+ await this.bridge.recordTranscript(displayEvent, row, true)
226
+ return
227
+ }
228
+
229
+ try {
230
+ // Keep the incoming QQ message visible in Web independently from the
231
+ // notice-shaped message used to drive the Agent turn.
232
+ await this.bridge.recordTranscript(displayEvent, row, true)
233
+ const targetId = message.chatType === 'group' ? message.groupOpenid! : message.senderId
234
+ const sendOptions = {
235
+ group: message.chatType === 'group', messageId: message.messageId || null,
236
+ format: message.chatType === 'group' ? settings.groupReplyFormat : settings.directReplyFormat,
237
+ } as const
238
+ const stream = message.chatType === 'c2c' && settings.directReplyFormat !== 'compat' && settings.directStreamingEnabled
239
+ ? this.api.createPrivateTextStream(account, targetId, sendOptions)
240
+ : undefined
241
+ const reply = await this.bridge.reply(message, group, member, storedAttachments, stream ? delta => stream.push(delta) : undefined, isOwner)
242
+ if (stream) {
243
+ try {
244
+ await stream.finish(reply)
245
+ } catch (error) {
246
+ this.logger.warn?.(`[dsh-qqchat] QQ private stream failed, falling back to normal send: ${error instanceof Error ? error.message : String(error)}`)
247
+ if (!stream.hasSent()) await this.api.sendReplyWithActiveFallback(account, targetId, reply, sendOptions)
248
+ else throw error
249
+ }
250
+ } else {
251
+ await this.api.sendReplyWithActiveFallback(account, targetId, reply, sendOptions)
252
+ }
253
+ this.db.insertMessage({
254
+ accountId: message.accountId, chatType: message.chatType, groupId: group?.id,
255
+ memberId: message.chatType === 'c2c' ? member.id : undefined, direction: 'outbound', content: reply, mentioned: false,
256
+ })
257
+ if (settings.memoryEnabled) {
258
+ if (group) this.memory.schedule(Number(group.id))
259
+ else this.memory.scheduleMember(Number(member.id))
260
+ }
261
+ } catch (error) {
262
+ this.logger.error?.(`[dsh-qqchat] QQ turn failed: ${error instanceof Error ? error.stack || error.message : String(error)}`)
263
+ }
264
+ }
265
+
266
+ private chatRow(chatType: ChatType, rowId: number): GroupRow | MemberRow | undefined {
267
+ return chatType === 'group' ? this.db.groupById(Number(rowId)) : this.db.memberById(Number(rowId))
268
+ }
269
+
270
+ private async flushOutbox(): Promise<void> {
271
+ const settings = this.settings()
272
+ for (const item of this.db.dueOutbox()) {
273
+ try {
274
+ const account = this.db.accountById(Number(item.account_id))
275
+ if (!account) throw new Error('QQ account missing')
276
+ await this.api.sendText(account, item.target_id, item.content, {
277
+ group: item.chat_type === 'group', messageId: null, format: item.chat_type === 'group' ? settings.groupReplyFormat : settings.directReplyFormat,
278
+ })
279
+ this.db.finishOutbox(Number(item.id))
280
+ } catch (error) {
281
+ this.db.finishOutbox(Number(item.id), error instanceof Error ? error.message : String(error))
282
+ }
283
+ }
284
+ }
285
+
286
+ }
287
+
288
+ function isGroupReceiveMode(value: unknown): value is GroupReceiveMode { return value === 'auto' || value === 'mention' || value === 'silent' }
289
+ function isReplyFormat(value: unknown): value is ReplyFormat { return value === 'smart' || value === 'markdown' || value === 'compat' }
290
+ export function shouldReplyToGroup(mode: GroupReceiveMode, group: Pick<GroupRow, 'enabled' | 'read_enabled'>, mentioned: boolean): boolean {
291
+ if (group.enabled !== 1 || group.read_enabled !== 1) return false
292
+ return mode === 'auto' || (mode === 'mention' && mentioned)
293
+ }
294
+ function shortId(value: string): string { return value.length > 10 ? `…${value.slice(-10)}` : value }
295
+ function publicAttachments(attachments: StoredAttachmentSummary[]): StoredAttachmentSummary[] {
296
+ return attachments.map(attachment => ({
297
+ id: String(attachment.id),
298
+ kind: attachment.kind,
299
+ filename: String(attachment.filename),
300
+ ...(attachment.contentType ? { contentType: String(attachment.contentType) } : {}),
301
+ sizeBytes: Number(attachment.sizeBytes || 0),
302
+ quoted: Boolean(attachment.quoted),
303
+ }))
304
+ }
305
+ function sanitizeQuote(quote: QQQuoteInput, includeAttachments = true, stored: StoredAttachmentSummary[] = []): QQQuoteInput {
306
+ const quotedStored = stored.filter(item => item.quoted)
307
+ return {
308
+ ...quote,
309
+ attachments: includeAttachments
310
+ ? quote.attachments.map(({ sourceUrl: _sourceUrl, ...attachment }, index) => {
311
+ const match = quotedStored.find(item => item.id === attachment.attachmentId || (attachment.platformFileId && item.sourceFileId === attachment.platformFileId) || item.sourceIndex === index)
312
+ return match ? { ...attachment, attachmentId: match.id } : attachment
313
+ })
314
+ : [],
315
+ }
316
+ }
317
+
318
+ function transcriptContent(text: string, attachments: StoredAttachmentSummary[]): string {
319
+ const body = text.trim()
320
+ if (body) return body
321
+ if (attachments.length === 0) return '(空消息)'
322
+ return attachments.map(attachment => `[${mediaLabel(attachment.kind)}] ${attachment.filename}`).join('\n')
323
+ }
324
+
325
+ function mediaLabel(kind: StoredAttachmentSummary['kind']): string {
326
+ if (kind === 'image') return '图片'
327
+ if (kind === 'video') return '视频'
328
+ if (kind === 'voice' || kind === 'audio') return '语音'
329
+ return '文件'
330
+ }
331
+
332
+ function dedupeAttachments(attachments: StoredAttachmentSummary[]): StoredAttachmentSummary[] {
333
+ const seen = new Set<string>()
334
+ return attachments.filter(attachment => {
335
+ if (seen.has(attachment.id)) return false
336
+ seen.add(attachment.id)
337
+ return true
338
+ })
339
+ }
340
+
341
+ export function mergeIndexedQuote(message: QQNormalizedMessage, indexed: import('../types.js').QQQuoteIndexRow | undefined): QQNormalizedMessage {
342
+ if (!indexed) return message
343
+ const current = message.quote
344
+ const attachments = current?.attachments?.length ? current.attachments : indexed.attachments.map(attachment => ({ ...attachment, quoted: true }))
345
+ message.quote = {
346
+ messageId: current?.messageId || indexed.platformMessageId,
347
+ senderId: current?.senderId || indexed.senderId,
348
+ senderName: current?.senderName || indexed.senderName,
349
+ text: current?.text || indexed.content,
350
+ attachments,
351
+ }
352
+ if (!message.quotedText) message.quotedText = indexed.content
353
+ return message
354
+ }
355
+
356
+ export function indexAttachments(inputs: QQAttachmentInput[], stored: StoredAttachmentSummary[]): QQAttachmentInput[] {
357
+ const ownStored = stored.filter(item => !item.quoted)
358
+ return inputs.map((input, index) => {
359
+ const match = ownStored.find(item => (input.platformFileId && item.sourceFileId === input.platformFileId) || item.sourceIndex === index)
360
+ return {
361
+ filename: input.filename, ...(input.contentType ? { contentType: input.contentType } : {}),
362
+ ...(input.size !== undefined ? { size: input.size } : {}), ...(input.width !== undefined ? { width: input.width } : {}),
363
+ ...(input.height !== undefined ? { height: input.height } : {}), ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}),
364
+ ...(input.platformFileId ? { platformFileId: input.platformFileId } : {}), ...(input.kind ? { kind: input.kind } : {}),
365
+ ...(match ? { attachmentId: match.id } : {}),
366
+ }
367
+ })
368
+ }
@@ -0,0 +1,45 @@
1
+ import type {} from '@deepseek-ai/dsh-llm'
2
+ import type {} from '@deepseek-ai/dsh-session'
3
+ import type { QQChatDisplayEvent } from '../types.js'
4
+
5
+ declare module '@deepseek-ai/dsh-llm' {
6
+ interface MessageSourceMap {
7
+ /** QQ message that should behave as an ordinary DSH user prompt while retaining reliable QQ identity metadata. */
8
+ 'qq-user': {
9
+ kind: 'user'
10
+ channel: 'qq'
11
+ botId: string
12
+ chatType: 'c2c' | 'group'
13
+ chatId: string
14
+ senderId: string
15
+ senderName?: string
16
+ messageId: string
17
+ mentioned: boolean
18
+ }
19
+ 'qq-chat': {
20
+ kind: 'qq-chat'
21
+ botId: string
22
+ chatType: 'c2c' | 'group'
23
+ chatId: string
24
+ senderId: string
25
+ senderName?: string
26
+ messageId: string
27
+ mentioned: boolean
28
+ form: 'notice'
29
+ summary: string
30
+ }
31
+ 'qq-chat-bootstrap': {
32
+ kind: 'qq-chat-bootstrap'
33
+ plugin: 'dsh-qqchat'
34
+ }
35
+ }
36
+ }
37
+
38
+ declare module '@deepseek-ai/dsh-session/types' {
39
+ interface SessionEventMap {
40
+ /** QQ transcript row used only for messages that do not enter the Agent/model surface. */
41
+ 'qqchat/message': QQChatDisplayEvent
42
+ }
43
+ }
44
+
45
+ export {}
@@ -0,0 +1,36 @@
1
+ import type { LogLevel, LoggerLike, PluginLogEntry } from '../types.js'
2
+
3
+ export class QQChatLogger implements LoggerLike {
4
+ private nextId = 1
5
+ private readonly entries: PluginLogEntry[] = []
6
+
7
+ constructor(
8
+ private readonly base: LoggerLike = console,
9
+ private readonly maxEntries = 500,
10
+ ) {}
11
+
12
+ debug = (...args: unknown[]): void => this.write('debug', args)
13
+ info = (...args: unknown[]): void => this.write('info', args)
14
+ warn = (...args: unknown[]): void => this.write('warn', args)
15
+ error = (...args: unknown[]): void => this.write('error', args)
16
+
17
+ list(limit = 200): PluginLogEntry[] {
18
+ const safe = Math.max(1, Math.min(this.maxEntries, Math.trunc(limit || 200)))
19
+ return this.entries.slice(-safe)
20
+ }
21
+
22
+ private write(level: LogLevel, args: unknown[]): void {
23
+ const message = args.map(formatArg).join(' ')
24
+ this.entries.push({ id: this.nextId++, time: Date.now(), level, message })
25
+ if (this.entries.length > this.maxEntries) this.entries.splice(0, this.entries.length - this.maxEntries)
26
+ const sink = this.base[level] ?? this.base.info
27
+ if (sink) sink.call(this.base, ...args)
28
+ else (console[level] ?? console.log)(...args)
29
+ }
30
+ }
31
+
32
+ function formatArg(value: unknown): string {
33
+ if (value instanceof Error) return value.stack || value.message
34
+ if (typeof value === 'string') return value
35
+ try { return JSON.stringify(value) } catch { return String(value) }
36
+ }