mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-17 01:40:22 +08:00
feat: 扩展 MCP AI 友好检索层
This commit is contained in:
@@ -27,6 +27,7 @@ export interface ContactInfo {
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
lastContactTime?: number
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -100,6 +101,22 @@ export interface Contact {
|
||||
nickName: string
|
||||
}
|
||||
|
||||
function compareMessageCursorAsc(
|
||||
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
||||
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
||||
): number {
|
||||
return Number(a.sortSeq || 0) - Number(b.sortSeq || 0)
|
||||
|| Number(a.createTime || 0) - Number(b.createTime || 0)
|
||||
|| Number(a.localId || 0) - Number(b.localId || 0)
|
||||
}
|
||||
|
||||
function compareMessageCursorDesc(
|
||||
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
||||
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
||||
): number {
|
||||
return compareMessageCursorAsc(b, a)
|
||||
}
|
||||
|
||||
// 表情包缓存
|
||||
const emojiCache: Map<string, string> = new Map()
|
||||
const emojiDownloading: Map<string, Promise<string | null>> = new Map()
|
||||
@@ -1001,16 +1018,16 @@ class ChatService extends EventEmitter {
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
} else if (hasName2Id) {
|
||||
sql = `SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
} else {
|
||||
sql = `SELECT * FROM ${tableName} ORDER BY sort_seq DESC LIMIT ? OFFSET ?`
|
||||
sql = `SELECT * FROM ${tableName} ORDER BY sort_seq DESC, create_time DESC, local_id DESC LIMIT ? OFFSET ?`
|
||||
}
|
||||
|
||||
const stmt = db.prepare(sql)
|
||||
@@ -1248,7 +1265,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
|
||||
// 按 sort_seq 降序排序(最新的在前)
|
||||
allMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
allMessages.sort(compareMessageCursorDesc)
|
||||
|
||||
// 去重(同一条消息可能在多个数据库中)
|
||||
const seen = new Set<string>()
|
||||
@@ -1363,7 +1380,7 @@ class ChatService extends EventEmitter {
|
||||
OR (m.sort_seq = ? AND m.create_time < ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
|
||||
)
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
myRowId,
|
||||
@@ -1384,7 +1401,7 @@ class ChatService extends EventEmitter {
|
||||
OR (m.sort_seq = ? AND m.create_time < ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
|
||||
)
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
@@ -1402,7 +1419,7 @@ class ChatService extends EventEmitter {
|
||||
OR (sort_seq = ? AND create_time < ?)
|
||||
OR (sort_seq = ? AND create_time = ? AND local_id < ?)
|
||||
)
|
||||
ORDER BY sort_seq DESC
|
||||
ORDER BY sort_seq DESC, create_time DESC, local_id DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
@@ -1536,7 +1553,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
allMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
allMessages.sort(compareMessageCursorDesc)
|
||||
|
||||
const seen = new Set<string>()
|
||||
allMessages = allMessages.filter(msg => {
|
||||
@@ -1557,6 +1574,277 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 sortSeq 游标,获取更新的消息(严格大于 cursorSortSeq)
|
||||
*/
|
||||
async getMessagesAfter(
|
||||
sessionId: string,
|
||||
cursorSortSeq: number,
|
||||
limit: number = 50,
|
||||
cursorCreateTime?: number,
|
||||
cursorLocalId?: number
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.dbDir) {
|
||||
const connectResult = await this.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
}
|
||||
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
if (dbTablePairs.length === 0) {
|
||||
return { success: false, error: '未找到该会话的消息表' }
|
||||
}
|
||||
|
||||
let allMessages: Message[] = []
|
||||
const fetchLimitPerDb = Math.max(limit + 1, 50)
|
||||
const effectiveCursorCreateTime = cursorCreateTime ?? Number.MIN_SAFE_INTEGER
|
||||
const effectiveCursorLocalId = cursorLocalId ?? Number.MIN_SAFE_INTEGER
|
||||
|
||||
for (const { db, tableName, dbPath } of dbTablePairs) {
|
||||
try {
|
||||
const hasName2IdTable = this.checkTableExists(db, 'Name2Id')
|
||||
|
||||
let myRowId: number | null = null
|
||||
if (myWxid && hasName2IdTable) {
|
||||
const cacheKeyOriginal = `${dbPath}:${myWxid}`
|
||||
const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal)
|
||||
|
||||
if (cachedRowIdOriginal !== undefined) {
|
||||
myRowId = cachedRowIdOriginal
|
||||
} else {
|
||||
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
|
||||
if (row?.rowid) {
|
||||
myRowId = row.rowid
|
||||
this.myRowIdCache.set(cacheKeyOriginal, myRowId)
|
||||
} else if (cleanedMyWxid && cleanedMyWxid !== myWxid) {
|
||||
const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}`
|
||||
const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned)
|
||||
|
||||
if (cachedRowIdCleaned !== undefined) {
|
||||
myRowId = cachedRowIdCleaned
|
||||
} else {
|
||||
const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any
|
||||
myRowId = row2?.rowid ?? null
|
||||
this.myRowIdCache.set(cacheKeyCleaned, myRowId)
|
||||
}
|
||||
} else {
|
||||
this.myRowIdCache.set(cacheKeyOriginal, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sql: string
|
||||
let rows: any[]
|
||||
|
||||
if (hasName2IdTable && myRowId !== null) {
|
||||
sql = `SELECT m.*,
|
||||
CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send,
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE (
|
||||
m.sort_seq > ?
|
||||
OR (m.sort_seq = ? AND m.create_time > ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id > ?)
|
||||
)
|
||||
ORDER BY m.sort_seq ASC, m.create_time ASC, m.local_id ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
myRowId,
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
} else if (hasName2IdTable) {
|
||||
sql = `SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE (
|
||||
m.sort_seq > ?
|
||||
OR (m.sort_seq = ? AND m.create_time > ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id > ?)
|
||||
)
|
||||
ORDER BY m.sort_seq ASC, m.create_time ASC, m.local_id ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
} else {
|
||||
sql = `SELECT * FROM ${tableName}
|
||||
WHERE (
|
||||
sort_seq > ?
|
||||
OR (sort_seq = ? AND create_time > ?)
|
||||
OR (sort_seq = ? AND create_time = ? AND local_id > ?)
|
||||
)
|
||||
ORDER BY sort_seq ASC, create_time ASC, local_id ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = row.local_type || row.type || 1
|
||||
const isSend = row.computed_is_send ?? row.is_send ?? null
|
||||
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
let emojiProductId: string | undefined
|
||||
let quotedContent: string | undefined
|
||||
let quotedSender: string | undefined
|
||||
let quotedImageMd5: string | undefined
|
||||
let quotedEmojiMd5: string | undefined
|
||||
let quotedEmojiCdnUrl: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let isLivePhoto: boolean | undefined
|
||||
let videoMd5: string | undefined
|
||||
let videoDuration: number | undefined
|
||||
let voiceDuration: number | undefined
|
||||
|
||||
if (localType === 47 && content) {
|
||||
const emojiInfo = this.parseEmojiInfo(content)
|
||||
emojiCdnUrl = emojiInfo.cdnUrl
|
||||
emojiMd5 = emojiInfo.md5
|
||||
emojiProductId = emojiInfo.productId
|
||||
} else if (localType === 3 && content) {
|
||||
const imageInfo = this.parseImageInfo(content)
|
||||
imageMd5 = imageInfo.md5
|
||||
imageDatName = this.parseImageDatNameFromRow(row)
|
||||
isLivePhoto = imageInfo.isLivePhoto
|
||||
} else if (localType === 43 && content) {
|
||||
videoMd5 = this.parseVideoMd5(content)
|
||||
videoDuration = this.parseVideoDuration(content)
|
||||
} else if (localType === 34 && content) {
|
||||
voiceDuration = this.parseVoiceDuration(content)
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
quotedSender = quoteInfo.sender
|
||||
quotedImageMd5 = quoteInfo.imageMd5
|
||||
quotedEmojiMd5 = quoteInfo.emojiMd5
|
||||
quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl
|
||||
}
|
||||
|
||||
let fileName: string | undefined
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let fileMd5: string | undefined
|
||||
if (localType === 49 && content) {
|
||||
const fileInfo = this.parseFileInfo(content)
|
||||
fileName = fileInfo.fileName
|
||||
fileSize = fileInfo.fileSize
|
||||
fileExt = fileInfo.fileExt
|
||||
fileMd5 = fileInfo.fileMd5
|
||||
}
|
||||
|
||||
let chatRecordList: ChatRecordItem[] | undefined
|
||||
if (content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '19' || localType === 49) {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
}
|
||||
}
|
||||
|
||||
let transferPayerUsername: string | undefined
|
||||
let transferReceiverUsername: string | undefined
|
||||
if ((localType === 49 || localType === 8589934592049) && content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '2000') {
|
||||
transferPayerUsername = this.extractXmlValue(content, 'payer_username') || undefined
|
||||
transferReceiverUsername = this.extractXmlValue(content, 'receiver_username') || undefined
|
||||
}
|
||||
}
|
||||
|
||||
const parsedContent = this.parseMessageContent(content, localType)
|
||||
|
||||
allMessages.push({
|
||||
localId: row.local_id || 0,
|
||||
serverId: row.server_id || 0,
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent,
|
||||
rawContent: content,
|
||||
emojiCdnUrl,
|
||||
emojiMd5,
|
||||
productId: emojiProductId,
|
||||
quotedContent,
|
||||
quotedSender,
|
||||
quotedImageMd5,
|
||||
quotedEmojiMd5,
|
||||
quotedEmojiCdnUrl,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
isLivePhoto,
|
||||
videoMd5,
|
||||
videoDuration,
|
||||
voiceDuration,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5,
|
||||
chatRecordList,
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'SQLITE_CORRUPT' || e?.message?.includes('malformed')) {
|
||||
console.error(`[ChatService] 数据库损坏: ${dbPath}`, e)
|
||||
this.messageDbCache.delete(dbPath)
|
||||
try { db.close() } catch { }
|
||||
this.refreshMessageDbCache()
|
||||
} else {
|
||||
console.error('ChatService: 查询更新消息失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allMessages.sort(compareMessageCursorAsc)
|
||||
|
||||
const seen = new Set<string>()
|
||||
allMessages = allMessages.filter(msg => {
|
||||
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const hasMore = allMessages.length > limit
|
||||
const messages = allMessages.slice(0, limit)
|
||||
|
||||
return { success: true, messages, hasMore }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取更新消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话的所有语音消息(用于批量转写)
|
||||
* 复用 getMessages 的查询逻辑,只查询语音消息类型
|
||||
@@ -1693,7 +1981,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
|
||||
// 按 sort_seq 降序排序
|
||||
allVoiceMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
allVoiceMessages.sort(compareMessageCursorDesc)
|
||||
|
||||
// 去重
|
||||
const seen = new Set<string>()
|
||||
|
||||
+601
-102
@@ -2,12 +2,38 @@ import { existsSync, mkdirSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { z } from 'zod'
|
||||
import { chatService } from '../chatService'
|
||||
import { chatService, type ChatSession, type ContactInfo, type Message } from '../chatService'
|
||||
import { ConfigService } from '../config'
|
||||
import { imageDecryptService } from '../imageDecryptService'
|
||||
import { videoService } from '../videoService'
|
||||
import { McpToolError } from './result'
|
||||
import type { McpMessageItem, McpMessagesPayload, McpSessionItem, McpSessionsPayload } from './types'
|
||||
import {
|
||||
MCP_CONTACT_KINDS,
|
||||
MCP_MESSAGE_KINDS,
|
||||
type McpContactItem,
|
||||
type McpContactKind,
|
||||
type McpContactsPayload,
|
||||
type McpCursor,
|
||||
type McpMessageItem,
|
||||
type McpMessageKind,
|
||||
type McpMessageMatchField,
|
||||
type McpMessagesPayload,
|
||||
type McpSearchHit,
|
||||
type McpSearchMessagesPayload,
|
||||
type McpSessionContextPayload,
|
||||
type McpSessionItem,
|
||||
type McpSessionKind,
|
||||
type McpSessionRef,
|
||||
type McpSessionsPayload
|
||||
} from './types'
|
||||
|
||||
const MAX_LIST_LIMIT = 200
|
||||
const MAX_SEARCH_LIMIT = 100
|
||||
const MAX_CONTEXT_LIMIT = 100
|
||||
const SEARCH_BATCH_SIZE = 200
|
||||
const MAX_SEARCH_SESSIONS = 20
|
||||
const MAX_SCAN_PER_SESSION = 1000
|
||||
const MAX_SCAN_GLOBAL = 10000
|
||||
|
||||
const listSessionsArgsSchema = z.object({
|
||||
q: z.string().optional(),
|
||||
@@ -28,22 +54,81 @@ const getMessagesArgsSchema = z.object({
|
||||
includeMediaPaths: z.boolean().optional()
|
||||
})
|
||||
|
||||
const listContactsArgsSchema = z.object({
|
||||
q: z.string().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
types: z.array(z.enum(MCP_CONTACT_KINDS)).optional()
|
||||
})
|
||||
|
||||
const searchMessagesArgsSchema = z.object({
|
||||
query: z.string().trim().min(1),
|
||||
sessionId: z.string().trim().min(1).optional(),
|
||||
sessionIds: z.array(z.string().trim().min(1)).max(MAX_SEARCH_SESSIONS).optional(),
|
||||
startTime: z.number().int().positive().optional(),
|
||||
endTime: z.number().int().positive().optional(),
|
||||
kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional(),
|
||||
direction: z.enum(['in', 'out']).optional(),
|
||||
senderUsername: z.string().trim().min(1).optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
includeRaw: z.boolean().optional(),
|
||||
includeMediaPaths: z.boolean().optional()
|
||||
})
|
||||
|
||||
const cursorSchema = z.object({
|
||||
sortSeq: z.number().int(),
|
||||
createTime: z.number().int().positive(),
|
||||
localId: z.number().int()
|
||||
})
|
||||
|
||||
const getSessionContextArgsSchema = z.object({
|
||||
sessionId: z.string().trim().min(1),
|
||||
mode: z.enum(['latest', 'around']),
|
||||
anchorCursor: cursorSchema.optional(),
|
||||
beforeLimit: z.number().int().positive().optional(),
|
||||
afterLimit: z.number().int().positive().optional(),
|
||||
includeRaw: z.boolean().optional(),
|
||||
includeMediaPaths: z.boolean().optional()
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.mode === 'around' && !value.anchorCursor) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['anchorCursor'],
|
||||
message: 'anchorCursor is required when mode=around'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type ListSessionsArgs = z.infer<typeof listSessionsArgsSchema>
|
||||
type GetMessagesArgs = z.infer<typeof getMessagesArgsSchema>
|
||||
type ListContactsArgs = z.infer<typeof listContactsArgsSchema>
|
||||
type SearchMessagesArgs = z.infer<typeof searchMessagesArgsSchema>
|
||||
type GetSessionContextArgs = z.infer<typeof getSessionContextArgsSchema>
|
||||
type ContactWithLastContact = ContactInfo & { lastContactTime?: number }
|
||||
type MessageNormalizeOptions = {
|
||||
includeMediaPaths: boolean
|
||||
includeRaw: boolean
|
||||
}
|
||||
type SearchRawHit = {
|
||||
session: McpSessionRef
|
||||
message: Message
|
||||
matchedField: McpMessageMatchField
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
function toTimestampMs(value?: number | null): number | null {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return null
|
||||
function toTimestampMs(value?: number | null): number {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return 0
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value
|
||||
}
|
||||
|
||||
function detectSessionKind(sessionId: string): McpSessionItem['kind'] {
|
||||
function detectSessionKind(sessionId: string): McpSessionKind {
|
||||
if (sessionId.includes('@chatroom')) return 'group'
|
||||
if (sessionId.startsWith('gh_')) return 'official'
|
||||
if (sessionId) return 'friend'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function detectMessageKind(message: Record<string, unknown>): string {
|
||||
function detectMessageKind(message: Pick<Message, 'localType' | 'rawContent' | 'parsedContent'>): McpMessageKind {
|
||||
const localType = Number(message.localType || 0)
|
||||
const raw = String(message.rawContent || message.parsedContent || '')
|
||||
const xmlTypeMatch = raw.match(/<type>\s*([^<]+)\s*<\/type>/i)
|
||||
@@ -94,6 +179,128 @@ function detectMessageKind(message: Record<string, unknown>): string {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function compareMessageCursorAsc(
|
||||
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
||||
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
||||
): number {
|
||||
return Number(a.sortSeq || 0) - Number(b.sortSeq || 0)
|
||||
|| Number(a.createTime || 0) - Number(b.createTime || 0)
|
||||
|| Number(a.localId || 0) - Number(b.localId || 0)
|
||||
}
|
||||
|
||||
function compareMessageCursorDesc(
|
||||
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
||||
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
||||
): number {
|
||||
return compareMessageCursorAsc(b, a)
|
||||
}
|
||||
|
||||
function buildCursor(message: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>): McpCursor {
|
||||
return {
|
||||
sortSeq: Number(message.sortSeq || 0),
|
||||
createTime: Number(message.createTime || 0),
|
||||
localId: Number(message.localId || 0)
|
||||
}
|
||||
}
|
||||
|
||||
function sameCursor(
|
||||
message: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
||||
cursor: McpCursor
|
||||
): boolean {
|
||||
return Number(message.sortSeq || 0) === cursor.sortSeq
|
||||
&& Number(message.createTime || 0) === cursor.createTime
|
||||
&& Number(message.localId || 0) === cursor.localId
|
||||
}
|
||||
|
||||
function uniqueMessageList(messages: Message[]): Message[] {
|
||||
const seen = new Set<string>()
|
||||
return messages.filter((message) => {
|
||||
const key = `${message.serverId}-${message.localId}-${message.createTime}-${message.sortSeq}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function createExcerpt(source: string, matchedIndex: number, queryLength: number): string {
|
||||
if (!source) return ''
|
||||
const radius = 48
|
||||
const safeIndex = Math.max(0, matchedIndex)
|
||||
const start = Math.max(0, safeIndex - radius)
|
||||
const end = Math.min(source.length, safeIndex + queryLength + radius)
|
||||
const prefix = start > 0 ? '...' : ''
|
||||
const suffix = end < source.length ? '...' : ''
|
||||
return `${prefix}${source.slice(start, end)}${suffix}`
|
||||
}
|
||||
|
||||
function findKeywordMatch(message: Message, query: string): { matchedField: McpMessageMatchField; excerpt: string } | null {
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
if (!normalizedQuery) return null
|
||||
|
||||
const text = String(message.parsedContent || '')
|
||||
const raw = String(message.rawContent || '')
|
||||
const textIndex = text.toLowerCase().indexOf(normalizedQuery)
|
||||
if (textIndex >= 0) {
|
||||
return {
|
||||
matchedField: 'text',
|
||||
excerpt: createExcerpt(text, textIndex, normalizedQuery.length)
|
||||
}
|
||||
}
|
||||
|
||||
const rawIndex = raw.toLowerCase().indexOf(normalizedQuery)
|
||||
if (rawIndex >= 0) {
|
||||
return {
|
||||
matchedField: 'raw',
|
||||
excerpt: createExcerpt(raw, rawIndex, normalizedQuery.length)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toSessionRef(session: Pick<ChatSession, 'username' | 'displayName'>): McpSessionRef {
|
||||
return {
|
||||
sessionId: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
kind: detectSessionKind(session.username)
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionItem(session: ChatSession): McpSessionItem {
|
||||
return {
|
||||
...toSessionRef(session),
|
||||
lastMessagePreview: session.summary || '',
|
||||
unreadCount: Number(session.unreadCount || 0),
|
||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||
lastTimestampMs: toTimestampMs(Number(session.lastTimestamp || 0))
|
||||
}
|
||||
}
|
||||
|
||||
function toContactItem(contact: ContactWithLastContact): McpContactItem {
|
||||
const lastContactTimestamp = Number(contact.lastContactTime || 0)
|
||||
return {
|
||||
contactId: contact.username,
|
||||
displayName: contact.displayName,
|
||||
remark: contact.remark || undefined,
|
||||
nickname: contact.nickname || undefined,
|
||||
kind: contact.type as McpContactKind,
|
||||
lastContactTimestamp,
|
||||
lastContactTimestampMs: toTimestampMs(lastContactTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSessionRef(sessionId: string, sessionMap: Map<string, McpSessionRef>): McpSessionRef {
|
||||
return sessionMap.get(sessionId) || {
|
||||
sessionId,
|
||||
displayName: sessionId,
|
||||
kind: detectSessionKind(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
function mapChatError(errorMessage?: string): never {
|
||||
const message = errorMessage || 'Unknown chat service error.'
|
||||
|
||||
@@ -103,7 +310,8 @@ function mapChatError(errorMessage?: string): never {
|
||||
message.includes('未找到账号') ||
|
||||
message.includes('未找到 session.db') ||
|
||||
message.includes('未找到会话表') ||
|
||||
message.includes('数据库未连接')
|
||||
message.includes('数据库未连接') ||
|
||||
message.includes('联系人数据库未连接')
|
||||
) {
|
||||
throw new McpToolError('DB_NOT_READY', 'Chat database is not ready.', message)
|
||||
}
|
||||
@@ -115,18 +323,15 @@ function mapChatError(errorMessage?: string): never {
|
||||
throw new McpToolError('INTERNAL_ERROR', 'Failed to query CipherTalk data.', message)
|
||||
}
|
||||
|
||||
async function getEmojiLocalPath(base: Record<string, unknown>): Promise<string | null> {
|
||||
const emojiMd5 = base.emojiMd5 as string | undefined
|
||||
const emojiCdnUrl = base.emojiCdnUrl as string | undefined
|
||||
|
||||
if (!emojiMd5 && !emojiCdnUrl) return null
|
||||
async function getEmojiLocalPath(message: Message): Promise<string | null> {
|
||||
if (!message.emojiMd5 && !message.emojiCdnUrl) return null
|
||||
|
||||
try {
|
||||
const result = await chatService.downloadEmoji(
|
||||
String(emojiCdnUrl || ''),
|
||||
emojiMd5,
|
||||
base.productId as string | undefined,
|
||||
Number(base.createTime || 0)
|
||||
String(message.emojiCdnUrl || ''),
|
||||
message.emojiMd5,
|
||||
message.productId,
|
||||
Number(message.createTime || 0)
|
||||
)
|
||||
|
||||
return result.success ? result.cachePath || result.localPath || null : null
|
||||
@@ -135,14 +340,14 @@ async function getEmojiLocalPath(base: Record<string, unknown>): Promise<string
|
||||
}
|
||||
}
|
||||
|
||||
async function getImageLocalPath(sessionId: string, base: Record<string, unknown>): Promise<string | null> {
|
||||
if (!base.imageMd5 && !base.imageDatName) return null
|
||||
async function getImageLocalPath(sessionId: string, message: Message): Promise<string | null> {
|
||||
if (!message.imageMd5 && !message.imageDatName) return null
|
||||
|
||||
try {
|
||||
const resolved = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5: base.imageMd5 as string | undefined,
|
||||
imageDatName: base.imageDatName as string | undefined
|
||||
imageMd5: message.imageMd5,
|
||||
imageDatName: message.imageDatName
|
||||
})
|
||||
|
||||
if (resolved.success && resolved.localPath) {
|
||||
@@ -151,8 +356,8 @@ async function getImageLocalPath(sessionId: string, base: Record<string, unknown
|
||||
|
||||
const decrypted = await imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5: base.imageMd5 as string | undefined,
|
||||
imageDatName: base.imageDatName as string | undefined,
|
||||
imageMd5: message.imageMd5,
|
||||
imageDatName: message.imageDatName,
|
||||
force: false
|
||||
})
|
||||
|
||||
@@ -162,20 +367,20 @@ async function getImageLocalPath(sessionId: string, base: Record<string, unknown
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoLocalPath(base: Record<string, unknown>): string | null {
|
||||
if (!base.videoMd5) return null
|
||||
function getVideoLocalPath(message: Message): string | null {
|
||||
if (!message.videoMd5) return null
|
||||
|
||||
try {
|
||||
const info = videoService.getVideoInfo(String(base.videoMd5))
|
||||
const info = videoService.getVideoInfo(String(message.videoMd5))
|
||||
return info.exists ? info.videoUrl || null : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getVoiceLocalPath(sessionId: string, base: Record<string, unknown>): Promise<string | null> {
|
||||
const localId = Number(base.localId || 0)
|
||||
const createTime = Number(base.createTime || 0)
|
||||
async function getVoiceLocalPath(sessionId: string, message: Message): Promise<string | null> {
|
||||
const localId = Number(message.localId || 0)
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (!localId || !createTime) return null
|
||||
|
||||
try {
|
||||
@@ -200,8 +405,8 @@ async function getVoiceLocalPath(sessionId: string, base: Record<string, unknown
|
||||
}
|
||||
}
|
||||
|
||||
function getFileLocalPath(base: Record<string, unknown>): string | null {
|
||||
const fileName = String(base.fileName || '')
|
||||
function getFileLocalPath(message: Message): string | null {
|
||||
const fileName = String(message.fileName || '')
|
||||
if (!fileName) return null
|
||||
|
||||
const configService = new ConfigService()
|
||||
@@ -210,7 +415,7 @@ function getFileLocalPath(base: Record<string, unknown>): string | null {
|
||||
const myWxid = String(configService.get('myWxid') || '')
|
||||
if (!dbPath || !myWxid) return null
|
||||
|
||||
const createTimeMs = toTimestampMs(Number(base.createTime || 0))
|
||||
const createTimeMs = toTimestampMs(Number(message.createTime || 0))
|
||||
const fileDate = createTimeMs ? new Date(createTimeMs) : new Date()
|
||||
const monthDir = `${fileDate.getFullYear()}-${String(fileDate.getMonth() + 1).padStart(2, '0')}`
|
||||
return join(dbPath, myWxid, 'msg', 'file', monthDir, fileName)
|
||||
@@ -219,71 +424,77 @@ function getFileLocalPath(base: Record<string, unknown>): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function toMcpMessage(sessionId: string, includeMediaPaths: boolean, includeRaw: boolean, message: Record<string, unknown>): Promise<McpMessageItem> {
|
||||
async function normalizeMessage(
|
||||
sessionId: string,
|
||||
message: Message,
|
||||
options: MessageNormalizeOptions
|
||||
): Promise<McpMessageItem> {
|
||||
const kind = detectMessageKind(message)
|
||||
const direction = Number(message.isSend) === 1 ? 'out' : 'in'
|
||||
const base: McpMessageItem = {
|
||||
const normalized: McpMessageItem = {
|
||||
messageId: Number(message.localId || message.serverId || 0),
|
||||
timestamp: Number(message.createTime || 0),
|
||||
timestampMs: toTimestampMs(Number(message.createTime || 0)),
|
||||
direction,
|
||||
kind,
|
||||
text: String(message.parsedContent || message.rawContent || ''),
|
||||
sender: {
|
||||
username: (message.senderUsername as string | null) ?? null,
|
||||
username: message.senderUsername ?? null,
|
||||
isSelf: direction === 'out'
|
||||
}
|
||||
},
|
||||
cursor: buildCursor(message)
|
||||
}
|
||||
|
||||
if (includeRaw) {
|
||||
base.raw = String(message.rawContent || '')
|
||||
if (options.includeRaw) {
|
||||
normalized.raw = String(message.rawContent || '')
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case 'emoji':
|
||||
base.media = {
|
||||
normalized.media = {
|
||||
type: 'emoji',
|
||||
md5: (message.emojiMd5 as string | undefined) || null
|
||||
md5: message.emojiMd5 || null
|
||||
}
|
||||
if (includeMediaPaths) {
|
||||
base.media.localPath = await getEmojiLocalPath(message)
|
||||
if (options.includeMediaPaths) {
|
||||
normalized.media.localPath = await getEmojiLocalPath(message)
|
||||
}
|
||||
break
|
||||
case 'image':
|
||||
base.media = {
|
||||
normalized.media = {
|
||||
type: 'image',
|
||||
md5: (message.imageMd5 as string | undefined) || null,
|
||||
md5: message.imageMd5 || null,
|
||||
isLivePhoto: Boolean(message.isLivePhoto)
|
||||
}
|
||||
if (includeMediaPaths) {
|
||||
base.media.localPath = await getImageLocalPath(sessionId, message)
|
||||
if (options.includeMediaPaths) {
|
||||
normalized.media.localPath = await getImageLocalPath(sessionId, message)
|
||||
}
|
||||
break
|
||||
case 'video':
|
||||
base.media = {
|
||||
normalized.media = {
|
||||
type: 'video',
|
||||
md5: (message.videoMd5 as string | undefined) || null,
|
||||
md5: message.videoMd5 || null,
|
||||
durationSeconds: Number(message.videoDuration || 0) || null,
|
||||
isLivePhoto: Boolean(message.isLivePhoto)
|
||||
}
|
||||
if (includeMediaPaths) {
|
||||
base.media.localPath = getVideoLocalPath(message)
|
||||
if (options.includeMediaPaths) {
|
||||
normalized.media.localPath = getVideoLocalPath(message)
|
||||
}
|
||||
break
|
||||
case 'voice':
|
||||
base.media = {
|
||||
normalized.media = {
|
||||
type: 'voice',
|
||||
durationSeconds: Number(message.voiceDuration || 0) || null
|
||||
}
|
||||
if (includeMediaPaths) {
|
||||
base.media.localPath = await getVoiceLocalPath(sessionId, message)
|
||||
if (options.includeMediaPaths) {
|
||||
normalized.media.localPath = await getVoiceLocalPath(sessionId, message)
|
||||
}
|
||||
break
|
||||
case 'app_file': {
|
||||
const localPath = includeMediaPaths ? getFileLocalPath(message) : null
|
||||
base.media = {
|
||||
const localPath = options.includeMediaPaths ? getFileLocalPath(message) : null
|
||||
normalized.media = {
|
||||
type: 'file',
|
||||
md5: (message.fileMd5 as string | undefined) || null,
|
||||
fileName: (message.fileName as string | undefined) || null,
|
||||
md5: message.fileMd5 || null,
|
||||
fileName: message.fileName || null,
|
||||
fileSize: Number(message.fileSize || 0) || null,
|
||||
localPath,
|
||||
exists: localPath ? existsSync(localPath) : null
|
||||
@@ -294,7 +505,69 @@ async function toMcpMessage(sessionId: string, includeMediaPaths: boolean, inclu
|
||||
break
|
||||
}
|
||||
|
||||
return base
|
||||
return normalized
|
||||
}
|
||||
|
||||
async function normalizeMessages(
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
options: MessageNormalizeOptions
|
||||
): Promise<McpMessageItem[]> {
|
||||
return Promise.all(messages.map((message) => normalizeMessage(sessionId, message, options)))
|
||||
}
|
||||
|
||||
async function getSessionCatalog(): Promise<{ items: McpSessionItem[]; map: Map<string, McpSessionRef> }> {
|
||||
const result = await chatService.getSessions()
|
||||
if (!result.success) {
|
||||
mapChatError(result.error)
|
||||
}
|
||||
|
||||
const items = (result.sessions || [])
|
||||
.map((session) => toSessionItem(session))
|
||||
.sort((a, b) => b.lastTimestamp - a.lastTimestamp || a.displayName.localeCompare(b.displayName, 'zh-CN'))
|
||||
|
||||
const map = new Map<string, McpSessionRef>()
|
||||
for (const item of items) {
|
||||
map.set(item.sessionId, {
|
||||
sessionId: item.sessionId,
|
||||
displayName: item.displayName,
|
||||
kind: item.kind
|
||||
})
|
||||
}
|
||||
|
||||
return { items, map }
|
||||
}
|
||||
|
||||
function messageMatchesFilters(
|
||||
message: Message,
|
||||
filters: {
|
||||
startTimeMs?: number
|
||||
endTimeMs?: number
|
||||
kinds?: Set<McpMessageKind>
|
||||
direction?: 'in' | 'out'
|
||||
senderUsername?: string
|
||||
}
|
||||
): boolean {
|
||||
const timestampMs = toTimestampMs(Number(message.createTime || 0))
|
||||
if (filters.startTimeMs && timestampMs < filters.startTimeMs) return false
|
||||
if (filters.endTimeMs && timestampMs > filters.endTimeMs) return false
|
||||
|
||||
if (filters.kinds?.size) {
|
||||
const kind = detectMessageKind(message)
|
||||
if (!filters.kinds.has(kind)) return false
|
||||
}
|
||||
|
||||
if (filters.direction) {
|
||||
const direction = Number(message.isSend) === 1 ? 'out' : 'in'
|
||||
if (direction !== filters.direction) return false
|
||||
}
|
||||
|
||||
if (filters.senderUsername) {
|
||||
const senderUsername = String(message.senderUsername || '').trim().toLowerCase()
|
||||
if (senderUsername !== filters.senderUsername) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export class McpReadService {
|
||||
@@ -304,24 +577,12 @@ export class McpReadService {
|
||||
throw new McpToolError('BAD_REQUEST', 'Invalid list_sessions arguments.', args.error.message)
|
||||
}
|
||||
|
||||
const query = String(args.data.q || '').trim().toLowerCase()
|
||||
const query = normalizeQuery(args.data.q)
|
||||
const offset = Math.max(0, args.data.offset ?? 0)
|
||||
const limit = Math.min(args.data.limit ?? 100, 200)
|
||||
const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT)
|
||||
const unreadOnly = Boolean(args.data.unreadOnly)
|
||||
|
||||
const result = await chatService.getSessions()
|
||||
if (!result.success) {
|
||||
mapChatError(result.error)
|
||||
}
|
||||
|
||||
let sessions = (result.sessions || []).map((session) => ({
|
||||
sessionId: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
kind: detectSessionKind(session.username),
|
||||
lastMessagePreview: session.summary || '',
|
||||
unreadCount: Number(session.unreadCount || 0),
|
||||
lastTimestamp: Number(session.lastTimestamp || 0)
|
||||
} satisfies McpSessionItem))
|
||||
let sessions = (await getSessionCatalog()).items
|
||||
|
||||
if (query) {
|
||||
sessions = sessions.filter((session) => {
|
||||
@@ -337,8 +598,6 @@ export class McpReadService {
|
||||
sessions = sessions.filter((session) => session.unreadCount > 0)
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.lastTimestamp - a.lastTimestamp)
|
||||
|
||||
const total = sessions.length
|
||||
const items = sessions.slice(offset, offset + limit)
|
||||
|
||||
@@ -351,6 +610,51 @@ export class McpReadService {
|
||||
}
|
||||
}
|
||||
|
||||
async listContacts(rawArgs: ListContactsArgs): Promise<McpContactsPayload> {
|
||||
const args = listContactsArgsSchema.safeParse(rawArgs)
|
||||
if (!args.success) {
|
||||
throw new McpToolError('BAD_REQUEST', 'Invalid list_contacts arguments.', args.error.message)
|
||||
}
|
||||
|
||||
const query = normalizeQuery(args.data.q)
|
||||
const offset = Math.max(0, args.data.offset ?? 0)
|
||||
const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT)
|
||||
const typeSet = args.data.types?.length ? new Set(args.data.types) : null
|
||||
|
||||
const result = await chatService.getContacts()
|
||||
if (!result.success) {
|
||||
mapChatError(result.error)
|
||||
}
|
||||
|
||||
let contacts = (result.contacts || []).map((contact) => toContactItem(contact as ContactWithLastContact))
|
||||
|
||||
if (typeSet) {
|
||||
contacts = contacts.filter((contact) => typeSet.has(contact.kind))
|
||||
}
|
||||
|
||||
if (query) {
|
||||
contacts = contacts.filter((contact) => {
|
||||
return [
|
||||
contact.contactId,
|
||||
contact.displayName,
|
||||
contact.remark || '',
|
||||
contact.nickname || ''
|
||||
].some((value) => value.toLowerCase().includes(query))
|
||||
})
|
||||
}
|
||||
|
||||
const total = contacts.length
|
||||
const items = contacts.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: offset + items.length < total
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(rawArgs: GetMessagesArgs, defaultIncludeMediaPaths: boolean): Promise<McpMessagesPayload> {
|
||||
const args = getMessagesArgsSchema.safeParse(rawArgs)
|
||||
if (!args.success) {
|
||||
@@ -365,22 +669,20 @@ export class McpReadService {
|
||||
} = args.data
|
||||
|
||||
const offset = Math.max(0, args.data.offset ?? 0)
|
||||
const limit = Math.min(args.data.limit ?? 50, 200)
|
||||
const limit = Math.min(args.data.limit ?? 50, MAX_LIST_LIMIT)
|
||||
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
|
||||
const keywordQuery = String(keyword || '').trim().toLowerCase()
|
||||
const keywordQuery = normalizeQuery(keyword)
|
||||
const startTimeMs = toTimestampMs(args.data.startTime)
|
||||
const endTimeMs = toTimestampMs(args.data.endTime)
|
||||
|
||||
const matched: Record<string, unknown>[] = []
|
||||
const batchSize = 200
|
||||
const maxScan = 5000
|
||||
const matched: Message[] = []
|
||||
let scanOffset = 0
|
||||
let scanned = 0
|
||||
let reachedEnd = false
|
||||
const targetCount = offset + limit + 1
|
||||
|
||||
while (scanned < maxScan && matched.length < targetCount) {
|
||||
const result = await chatService.getMessages(sessionId, scanOffset, batchSize)
|
||||
while (scanned < 5000 && matched.length < targetCount) {
|
||||
const result = await chatService.getMessages(sessionId, scanOffset, SEARCH_BATCH_SIZE)
|
||||
if (!result.success) {
|
||||
mapChatError(result.error)
|
||||
}
|
||||
@@ -392,17 +694,9 @@ export class McpReadService {
|
||||
}
|
||||
|
||||
for (const message of part) {
|
||||
const timestampMs = toTimestampMs(Number(message.createTime || 0)) || 0
|
||||
const parsedContent = String(message.parsedContent || '')
|
||||
const rawContent = String(message.rawContent || '')
|
||||
|
||||
if (startTimeMs && timestampMs < startTimeMs) continue
|
||||
if (endTimeMs && timestampMs > endTimeMs) continue
|
||||
if (keywordQuery && !parsedContent.toLowerCase().includes(keywordQuery) && !rawContent.toLowerCase().includes(keywordQuery)) {
|
||||
continue
|
||||
}
|
||||
|
||||
matched.push(message as unknown as Record<string, unknown>)
|
||||
if (!messageMatchesFilters(message, { startTimeMs, endTimeMs })) continue
|
||||
if (keywordQuery && !findKeywordMatch(message, keywordQuery)) continue
|
||||
matched.push(message)
|
||||
}
|
||||
|
||||
scanOffset += part.length
|
||||
@@ -414,18 +708,10 @@ export class McpReadService {
|
||||
}
|
||||
}
|
||||
|
||||
matched.sort((a, b) => {
|
||||
const timeDelta = Number(a.createTime || 0) - Number(b.createTime || 0)
|
||||
if (timeDelta !== 0) {
|
||||
return order === 'asc' ? timeDelta : -timeDelta
|
||||
}
|
||||
|
||||
const idDelta = Number(a.localId || 0) - Number(b.localId || 0)
|
||||
return order === 'asc' ? idDelta : -idDelta
|
||||
})
|
||||
matched.sort((a, b) => order === 'asc' ? compareMessageCursorAsc(a, b) : compareMessageCursorDesc(a, b))
|
||||
|
||||
const page = matched.slice(offset, offset + limit)
|
||||
const items = await Promise.all(page.map((message) => toMcpMessage(sessionId, includeMediaPaths, includeRaw, message)))
|
||||
const items = await normalizeMessages(sessionId, page, { includeMediaPaths, includeRaw })
|
||||
|
||||
return {
|
||||
items,
|
||||
@@ -434,4 +720,217 @@ export class McpReadService {
|
||||
hasMore: reachedEnd ? matched.length > offset + items.length : true
|
||||
}
|
||||
}
|
||||
|
||||
async searchMessages(rawArgs: SearchMessagesArgs, defaultIncludeMediaPaths: boolean): Promise<McpSearchMessagesPayload> {
|
||||
const args = searchMessagesArgsSchema.safeParse(rawArgs)
|
||||
if (!args.success) {
|
||||
throw new McpToolError('BAD_REQUEST', 'Invalid search_messages arguments.', args.error.message)
|
||||
}
|
||||
|
||||
const { items: sessions, map: sessionMap } = await getSessionCatalog()
|
||||
const includeRaw = args.data.includeRaw ?? false
|
||||
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
|
||||
const limit = Math.min(args.data.limit ?? 20, MAX_SEARCH_LIMIT)
|
||||
const sessionIdCandidates = Array.from(new Set([
|
||||
...(args.data.sessionId ? [args.data.sessionId] : []),
|
||||
...(args.data.sessionIds || [])
|
||||
]))
|
||||
|
||||
if (sessionIdCandidates.length > MAX_SEARCH_SESSIONS) {
|
||||
throw new McpToolError('BAD_REQUEST', `At most ${MAX_SEARCH_SESSIONS} sessionIds can be searched at once.`)
|
||||
}
|
||||
|
||||
const targetSessions = sessionIdCandidates.length > 0
|
||||
? sessionIdCandidates.map((sessionId) => resolveSessionRef(sessionId, sessionMap))
|
||||
: sessions.slice(0, MAX_SEARCH_SESSIONS).map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
displayName: session.displayName,
|
||||
kind: session.kind
|
||||
}))
|
||||
|
||||
const kindSet = args.data.kinds?.length ? new Set(args.data.kinds) : undefined
|
||||
const senderUsername = normalizeQuery(args.data.senderUsername)
|
||||
const startTimeMs = toTimestampMs(args.data.startTime)
|
||||
const endTimeMs = toTimestampMs(args.data.endTime)
|
||||
|
||||
const rawHits: SearchRawHit[] = []
|
||||
let sessionsScanned = 0
|
||||
let messagesScanned = 0
|
||||
let truncated = false
|
||||
|
||||
for (const session of targetSessions) {
|
||||
sessionsScanned += 1
|
||||
|
||||
let sessionOffset = 0
|
||||
let sessionScanned = 0
|
||||
|
||||
while (sessionScanned < MAX_SCAN_PER_SESSION && messagesScanned < MAX_SCAN_GLOBAL) {
|
||||
const fetchLimit = Math.min(
|
||||
SEARCH_BATCH_SIZE,
|
||||
MAX_SCAN_PER_SESSION - sessionScanned,
|
||||
MAX_SCAN_GLOBAL - messagesScanned
|
||||
)
|
||||
|
||||
if (fetchLimit <= 0) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
|
||||
const result = await chatService.getMessages(session.sessionId, sessionOffset, fetchLimit)
|
||||
if (!result.success) {
|
||||
mapChatError(result.error)
|
||||
}
|
||||
|
||||
const part = result.messages || []
|
||||
if (part.length === 0) break
|
||||
|
||||
sessionOffset += part.length
|
||||
sessionScanned += part.length
|
||||
messagesScanned += part.length
|
||||
|
||||
for (const message of part) {
|
||||
if (!messageMatchesFilters(message, {
|
||||
startTimeMs,
|
||||
endTimeMs,
|
||||
kinds: kindSet,
|
||||
direction: args.data.direction,
|
||||
senderUsername
|
||||
})) {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = findKeywordMatch(message, args.data.query)
|
||||
if (!match) continue
|
||||
|
||||
rawHits.push({
|
||||
session,
|
||||
message,
|
||||
matchedField: match.matchedField,
|
||||
excerpt: match.excerpt
|
||||
})
|
||||
}
|
||||
|
||||
if (!result.hasMore) break
|
||||
}
|
||||
|
||||
if (messagesScanned >= MAX_SCAN_GLOBAL) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
rawHits.sort((a, b) => compareMessageCursorDesc(a.message, b.message))
|
||||
|
||||
const hits = await Promise.all(rawHits.slice(0, limit).map(async (hit): Promise<McpSearchHit> => ({
|
||||
session: hit.session,
|
||||
message: await normalizeMessage(hit.session.sessionId, hit.message, {
|
||||
includeMediaPaths,
|
||||
includeRaw
|
||||
}),
|
||||
excerpt: hit.excerpt,
|
||||
matchedField: hit.matchedField
|
||||
})))
|
||||
|
||||
return {
|
||||
hits,
|
||||
limit,
|
||||
sessionsScanned,
|
||||
messagesScanned,
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionContext(rawArgs: GetSessionContextArgs, defaultIncludeMediaPaths: boolean): Promise<McpSessionContextPayload> {
|
||||
const args = getSessionContextArgsSchema.safeParse(rawArgs)
|
||||
if (!args.success) {
|
||||
throw new McpToolError('BAD_REQUEST', 'Invalid get_session_context arguments.', args.error.message)
|
||||
}
|
||||
|
||||
const { map: sessionMap } = await getSessionCatalog()
|
||||
const session = resolveSessionRef(args.data.sessionId, sessionMap)
|
||||
const includeRaw = args.data.includeRaw ?? false
|
||||
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
|
||||
|
||||
if (args.data.mode === 'latest') {
|
||||
const latestLimit = Math.min(args.data.beforeLimit ?? 30, MAX_CONTEXT_LIMIT)
|
||||
const result = await chatService.getMessages(args.data.sessionId, 0, latestLimit)
|
||||
if (!result.success) {
|
||||
mapChatError(result.error)
|
||||
}
|
||||
|
||||
const messages = await normalizeMessages(args.data.sessionId, result.messages || [], {
|
||||
includeMediaPaths,
|
||||
includeRaw
|
||||
})
|
||||
|
||||
return {
|
||||
session,
|
||||
mode: 'latest',
|
||||
items: messages,
|
||||
hasMoreBefore: Boolean(result.hasMore),
|
||||
hasMoreAfter: false
|
||||
}
|
||||
}
|
||||
|
||||
const anchorCursor = args.data.anchorCursor!
|
||||
const beforeLimit = Math.min(args.data.beforeLimit ?? 20, MAX_CONTEXT_LIMIT)
|
||||
const afterLimit = Math.min(args.data.afterLimit ?? 20, MAX_CONTEXT_LIMIT)
|
||||
|
||||
const [beforeResult, anchorResult, afterResult] = await Promise.all([
|
||||
chatService.getMessagesBefore(
|
||||
args.data.sessionId,
|
||||
anchorCursor.sortSeq,
|
||||
beforeLimit,
|
||||
anchorCursor.createTime,
|
||||
anchorCursor.localId
|
||||
),
|
||||
chatService.getMessagesAfter(
|
||||
args.data.sessionId,
|
||||
anchorCursor.sortSeq,
|
||||
1,
|
||||
anchorCursor.createTime,
|
||||
anchorCursor.localId - 1
|
||||
),
|
||||
chatService.getMessagesAfter(
|
||||
args.data.sessionId,
|
||||
anchorCursor.sortSeq,
|
||||
afterLimit,
|
||||
anchorCursor.createTime,
|
||||
anchorCursor.localId
|
||||
)
|
||||
])
|
||||
|
||||
if (!beforeResult.success) mapChatError(beforeResult.error)
|
||||
if (!anchorResult.success) mapChatError(anchorResult.error)
|
||||
if (!afterResult.success) mapChatError(afterResult.error)
|
||||
|
||||
const anchorMessage = (anchorResult.messages || []).find((message) => sameCursor(message, anchorCursor))
|
||||
if (!anchorMessage) {
|
||||
throw new McpToolError('BAD_REQUEST', 'Anchor cursor was not found in this session.')
|
||||
}
|
||||
|
||||
const [beforeItems, anchorItem, afterItems] = await Promise.all([
|
||||
normalizeMessages(args.data.sessionId, beforeResult.messages || [], {
|
||||
includeMediaPaths,
|
||||
includeRaw
|
||||
}),
|
||||
normalizeMessage(args.data.sessionId, anchorMessage, {
|
||||
includeMediaPaths,
|
||||
includeRaw
|
||||
}),
|
||||
normalizeMessages(args.data.sessionId, afterResult.messages || [], {
|
||||
includeMediaPaths,
|
||||
includeRaw
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
session,
|
||||
mode: 'around',
|
||||
anchor: anchorItem,
|
||||
items: [...beforeItems, anchorItem, ...afterItems],
|
||||
hasMoreBefore: Boolean(beforeResult.hasMore),
|
||||
hasMoreAfter: Boolean(afterResult.hasMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { createToolError, createToolSuccess } from './result'
|
||||
import { getMcpConfigSnapshot, getMcpHealthPayload, getMcpStatusPayload } from './runtime'
|
||||
import { McpReadService } from './service'
|
||||
import { MCP_CONTACT_KINDS, MCP_MESSAGE_KINDS } from './types'
|
||||
|
||||
const readService = new McpReadService()
|
||||
|
||||
@@ -71,4 +72,74 @@ export function registerCipherTalkMcpTools(server: any) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('list_contacts', {
|
||||
title: 'List Contacts',
|
||||
description: 'List contacts, groups, and official accounts for agent-side resolution.',
|
||||
inputSchema: {
|
||||
q: z.string().optional().describe('Optional search keyword.'),
|
||||
offset: z.number().int().nonnegative().optional().describe('Pagination offset.'),
|
||||
limit: z.number().int().positive().optional().describe('Pagination limit.'),
|
||||
types: z.array(z.enum(MCP_CONTACT_KINDS)).optional().describe('Optional contact kinds to include.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.listContacts((args || {}) as any)
|
||||
return createToolSuccess(`Loaded ${payload.items.length} contacts.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('search_messages', {
|
||||
title: 'Search Messages',
|
||||
description: 'Search messages across one or more sessions and return agent-friendly hits.',
|
||||
inputSchema: {
|
||||
query: z.string().trim().min(1).describe('Required full-text query.'),
|
||||
sessionId: z.string().trim().min(1).optional().describe('Single session identifier to search.'),
|
||||
sessionIds: z.array(z.string().trim().min(1)).max(20).optional().describe('Multiple session identifiers to search.'),
|
||||
startTime: z.number().int().positive().optional().describe('Start timestamp in seconds or milliseconds.'),
|
||||
endTime: z.number().int().positive().optional().describe('End timestamp in seconds or milliseconds.'),
|
||||
kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional().describe('Optional message kinds to include.'),
|
||||
direction: z.enum(['in', 'out']).optional().describe('Optional direction filter.'),
|
||||
senderUsername: z.string().trim().min(1).optional().describe('Optional sender username filter.'),
|
||||
limit: z.number().int().positive().optional().describe('Maximum number of hits to return.'),
|
||||
includeRaw: z.boolean().optional().describe('Include raw message content when true.'),
|
||||
includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.searchMessages((args || {}) as any, defaults.mcpExposeMediaPaths)
|
||||
return createToolSuccess(`Loaded ${payload.hits.length} message hits.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_session_context', {
|
||||
title: 'Get Session Context',
|
||||
description: 'Return the latest session context or messages around a cursor anchor.',
|
||||
inputSchema: {
|
||||
sessionId: z.string().trim().min(1).describe('Required session identifier / username.'),
|
||||
mode: z.enum(['latest', 'around']).describe('Context mode.'),
|
||||
anchorCursor: z.object({
|
||||
sortSeq: z.number().int(),
|
||||
createTime: z.number().int().positive(),
|
||||
localId: z.number().int()
|
||||
}).optional().describe('Required cursor when mode=around.'),
|
||||
beforeLimit: z.number().int().positive().optional().describe('Latest count or before-context count.'),
|
||||
afterLimit: z.number().int().positive().optional().describe('After-context count when mode=around.'),
|
||||
includeRaw: z.boolean().optional().describe('Include raw message content when true.'),
|
||||
includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.getSessionContext((args || {}) as any, defaults.mcpExposeMediaPaths)
|
||||
return createToolSuccess(`Loaded ${payload.items.length} context messages.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,13 +2,55 @@ export const MCP_TOOL_NAMES = [
|
||||
'health_check',
|
||||
'get_status',
|
||||
'list_sessions',
|
||||
'get_messages'
|
||||
'get_messages',
|
||||
'list_contacts',
|
||||
'search_messages',
|
||||
'get_session_context'
|
||||
] as const
|
||||
|
||||
export const MCP_CONTACT_KINDS = [
|
||||
'friend',
|
||||
'group',
|
||||
'official',
|
||||
'former_friend',
|
||||
'other'
|
||||
] as const
|
||||
|
||||
export const MCP_MESSAGE_KINDS = [
|
||||
'text',
|
||||
'image',
|
||||
'voice',
|
||||
'contact_card',
|
||||
'video',
|
||||
'emoji',
|
||||
'location',
|
||||
'voip',
|
||||
'system',
|
||||
'quote',
|
||||
'app_music',
|
||||
'app_link',
|
||||
'app_file',
|
||||
'app_chat_record',
|
||||
'app_mini_program',
|
||||
'app_quote',
|
||||
'app_pat',
|
||||
'app_announcement',
|
||||
'app_gift',
|
||||
'app_transfer',
|
||||
'app_red_packet',
|
||||
'app',
|
||||
'unknown'
|
||||
] as const
|
||||
|
||||
export type McpToolName = (typeof MCP_TOOL_NAMES)[number]
|
||||
export type McpContactKind = (typeof MCP_CONTACT_KINDS)[number]
|
||||
export type McpMessageKind = (typeof MCP_MESSAGE_KINDS)[number]
|
||||
|
||||
export type McpLaunchMode = 'dev' | 'packaged'
|
||||
export type McpLauncherMode = 'dev-runner' | 'packaged-launcher' | 'direct'
|
||||
export type McpSessionKind = 'friend' | 'group' | 'official' | 'other'
|
||||
export type McpMessageMatchField = 'text' | 'raw'
|
||||
export type McpSessionContextMode = 'latest' | 'around'
|
||||
|
||||
export interface McpLaunchConfig {
|
||||
command: string
|
||||
@@ -54,13 +96,17 @@ export interface McpStatusPayload {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface McpSessionItem {
|
||||
export interface McpSessionRef {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
kind: 'friend' | 'group' | 'official' | 'other'
|
||||
kind: McpSessionKind
|
||||
}
|
||||
|
||||
export interface McpSessionItem extends McpSessionRef {
|
||||
lastMessagePreview: string
|
||||
unreadCount: number
|
||||
lastTimestamp: number
|
||||
lastTimestampMs: number
|
||||
}
|
||||
|
||||
export interface McpSessionsPayload {
|
||||
@@ -71,6 +117,30 @@ export interface McpSessionsPayload {
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface McpContactItem {
|
||||
contactId: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
kind: McpContactKind
|
||||
lastContactTimestamp: number
|
||||
lastContactTimestampMs: number
|
||||
}
|
||||
|
||||
export interface McpContactsPayload {
|
||||
items: McpContactItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface McpCursor {
|
||||
sortSeq: number
|
||||
createTime: number
|
||||
localId: number
|
||||
}
|
||||
|
||||
export interface McpMessageMedia {
|
||||
type: string
|
||||
localPath?: string | null
|
||||
@@ -85,13 +155,15 @@ export interface McpMessageMedia {
|
||||
export interface McpMessageItem {
|
||||
messageId: number
|
||||
timestamp: number
|
||||
timestampMs: number
|
||||
direction: 'in' | 'out'
|
||||
kind: string
|
||||
kind: McpMessageKind
|
||||
text: string
|
||||
sender: {
|
||||
username: string | null
|
||||
isSelf: boolean
|
||||
}
|
||||
cursor: McpCursor
|
||||
media?: McpMessageMedia
|
||||
raw?: string
|
||||
}
|
||||
@@ -102,3 +174,27 @@ export interface McpMessagesPayload {
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface McpSearchHit {
|
||||
session: McpSessionRef
|
||||
message: McpMessageItem
|
||||
excerpt: string
|
||||
matchedField: McpMessageMatchField
|
||||
}
|
||||
|
||||
export interface McpSearchMessagesPayload {
|
||||
hits: McpSearchHit[]
|
||||
limit: number
|
||||
sessionsScanned: number
|
||||
messagesScanned: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface McpSessionContextPayload {
|
||||
session: McpSessionRef
|
||||
mode: McpSessionContextMode
|
||||
anchor?: McpMessageItem
|
||||
items: McpMessageItem[]
|
||||
hasMoreBefore: boolean
|
||||
hasMoreAfter: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user