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.
- package/LICENSE +21 -0
- package/README.en.md +276 -0
- package/README.md +211 -0
- package/client-src/plugin.cts +15 -0
- package/client-src/settings.cts +216 -0
- package/client-src/shared.cts +104 -0
- package/client-src/workspace.cts +164 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.en.md +346 -0
- package/docs/ARCHITECTURE.md +411 -0
- package/docs/DEVELOPMENT.md +69 -0
- package/docs/MEDIA_AND_QUOTES.md +644 -0
- package/docs/MEMORY.md +99 -0
- package/docs/MEMORY_CONTEXT.md +254 -0
- package/docs/QUOTES.md +355 -0
- package/docs/README.md +22 -0
- package/docs/SECURITY.md +62 -0
- package/docs/STREAMING.md +347 -0
- package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
- package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
- package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/docs/devlog.md +24 -0
- package/lib/client.js +433 -0
- package/lib/commands/dispatch.d.ts +16 -0
- package/lib/commands/dispatch.js +48 -0
- package/lib/config.d.ts +3 -0
- package/lib/config.js +63 -0
- package/lib/gateway/api.d.ts +30 -0
- package/lib/gateway/api.js +202 -0
- package/lib/gateway/auth.d.ts +35 -0
- package/lib/gateway/auth.js +80 -0
- package/lib/gateway/crypto.d.ts +2 -0
- package/lib/gateway/crypto.js +17 -0
- package/lib/gateway/gateway.d.ts +32 -0
- package/lib/gateway/gateway.js +219 -0
- package/lib/gateway/normalize.d.ts +6 -0
- package/lib/gateway/normalize.js +248 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +35 -0
- package/lib/media/image-tool.d.ts +4 -0
- package/lib/media/image-tool.js +62 -0
- package/lib/media/media-tools.d.ts +3 -0
- package/lib/media/media-tools.js +68 -0
- package/lib/media/store.d.ts +14 -0
- package/lib/media/store.js +176 -0
- package/lib/session/agent-bridge.d.ts +56 -0
- package/lib/session/agent-bridge.js +613 -0
- package/lib/session/memory-snapshot.d.ts +23 -0
- package/lib/session/memory-snapshot.js +27 -0
- package/lib/session/runtime.d.ts +41 -0
- package/lib/session/runtime.js +395 -0
- package/lib/shared/augmentations.d.ts +40 -0
- package/lib/shared/augmentations.js +1 -0
- package/lib/shared/logging.d.ts +14 -0
- package/lib/shared/logging.js +41 -0
- package/lib/storage/db.d.ts +82 -0
- package/lib/storage/db.js +669 -0
- package/lib/storage/memory.d.ts +50 -0
- package/lib/storage/memory.js +566 -0
- package/lib/transport/rpc.d.ts +18 -0
- package/lib/transport/rpc.js +318 -0
- package/lib/types.d.ts +416 -0
- package/lib/types.js +1 -0
- package/package.json +115 -0
- package/scripts/wrap-client.mjs +20 -0
- package/src/commands/dispatch.ts +75 -0
- package/src/config.ts +70 -0
- package/src/gateway/api.ts +260 -0
- package/src/gateway/auth.ts +99 -0
- package/src/gateway/crypto.ts +16 -0
- package/src/gateway/gateway.ts +207 -0
- package/src/gateway/normalize.ts +236 -0
- package/src/index.ts +46 -0
- package/src/media/image-tool.ts +62 -0
- package/src/media/media-tools.ts +66 -0
- package/src/media/store.ts +157 -0
- package/src/session/agent-bridge.ts +624 -0
- package/src/session/memory-snapshot.ts +41 -0
- package/src/session/runtime.ts +368 -0
- package/src/shared/augmentations.ts +45 -0
- package/src/shared/logging.ts +36 -0
- package/src/storage/db.ts +760 -0
- package/src/storage/memory.ts +581 -0
- package/src/transport/rpc.ts +316 -0
- package/src/types.ts +456 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.client.json +14 -0
- 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
|
+
}
|