feat: 扩展 MCP AI 友好检索层

This commit is contained in:
ILoveBingLu
2026-04-01 22:00:47 +08:00
parent f5ca04ad51
commit fe8bcf7cd9
6 changed files with 1074 additions and 117 deletions
+297 -9
View File
@@ -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
View File
@@ -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)
}
}
}
+71
View File
@@ -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)
}
})
}
+100 -4
View File
@@ -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
}