feat: add moments data to mcp and http api

This commit is contained in:
ILoveBingLu
2026-04-07 14:45:08 +08:00
parent e67640a4c4
commit 69d875f788
8 changed files with 376 additions and 2 deletions

View File

@@ -306,6 +306,7 @@ AI 生成说明的密钥来源:
- `health_check`
- `get_status`
- `get_moments_timeline`
- `list_sessions`
- `get_messages`
- `list_contacts`
@@ -375,6 +376,25 @@ macOS 打包态请直接指向 `.app` 内部的 `ciphertalk-mcp`,不要把 `Ci
}
```
朋友圈时间线示例:
```json
{
"name": "get_moments_timeline",
"arguments": {
"limit": 20,
"offset": 0,
"usernames": ["wxid_xxx"],
"keyword": "旅行",
"startTime": 1704067200,
"endTime": 1735689599,
"includeRaw": false
}
}
```
第一版返回朋友圈结构化时间线,不包含媒体下载或本地路径解析接口。
---
## 💻 开发指南

View File

@@ -5,6 +5,7 @@ import { join } from 'path'
import { ConfigService } from './config'
import { chatService } from './chatService'
import { imageDecryptService } from './imageDecryptService'
import { snsService } from './snsService'
import { videoService } from './videoService'
import { getAppVersion } from './runtimePaths'
@@ -86,6 +87,16 @@ export interface QueryContactsInput {
limit?: number
}
export interface QuerySnsInput {
limit?: number
offset?: number
usernames?: string[] | null
keyword?: string
startTime?: number | null
endTime?: number | null
includeRaw?: boolean
}
function parseBoolean(value: string | null | undefined, defaultValue: boolean): boolean {
if (value == null) return defaultValue
const normalized = value.trim().toLowerCase()
@@ -927,3 +938,66 @@ export async function queryContacts(input: QueryContactsInput) {
contacts: finalContacts
}
}
export async function querySnsTimeline(input: QuerySnsInput) {
const limit = parseIntInRange(input.limit, 20, 1, 200)
const offset = parseIntInRange(input.offset, 0, 0, 100000)
const includeRaw = Boolean(input.includeRaw)
const usernames = input.usernames?.map((item) => String(item || '').trim()).filter(Boolean) || undefined
const keyword = String(input.keyword || '').trim() || undefined
const startTime = parseTimestampMs(input.startTime)
const endTime = parseTimestampMs(input.endTime)
const normalizedStartTime = startTime ? Math.floor(startTime / 1000) : undefined
const normalizedEndTime = endTime ? Math.floor(endTime / 1000) : undefined
const result = await snsService.getTimeline(
limit,
offset,
usernames,
keyword,
normalizedStartTime,
normalizedEndTime
)
if (!result.success) {
throw new ApiQueryError(
503,
'DB_NOT_READY',
result.error || 'Failed to read moments timeline',
'请先在首页或设置中完成数据库解密与连接,然后再调用 /v1/sns'
)
}
const items = (result.timeline || []).map((item) => ({
id: String(item.id || ''),
username: String(item.username || ''),
nickname: String(item.nickname || item.username || ''),
avatarUrl: item.avatarUrl || null,
createTime: Number(item.createTime || 0),
createTimeMs: normalizeTimestampMs(Number(item.createTime || 0)),
contentDesc: String(item.contentDesc || ''),
type: item.type ?? null,
media: Array.isArray(item.media) ? item.media : [],
shareInfo: item.shareInfo || null,
likes: Array.isArray(item.likes) ? item.likes : [],
comments: Array.isArray(item.comments) ? item.comments : [],
...(includeRaw ? { rawXml: item.rawXml || null } : {})
}))
const hasMore = items.length >= limit
return {
total: hasMore ? null : offset + items.length,
offset,
limit,
hasMore,
filters: {
usernames: usernames || null,
keyword: keyword || '',
startTime: startTime,
endTime: endTime,
includeRaw
},
items
}
}

View File

@@ -6,6 +6,7 @@ import { writeFile } from 'fs/promises'
import { join } from 'path'
import { ConfigService } from './config'
import { chatService } from './chatService'
import { querySnsTimeline } from './httpApiFacade'
import { imageDecryptService } from './imageDecryptService'
import { videoService } from './videoService'
@@ -145,7 +146,8 @@ class HttpApiService {
{ method: 'GET', path: '/v1/status', desc: '服务状态' },
{ method: 'GET', path: '/v1/sessions', desc: '会话列表' },
{ method: 'GET', path: '/v1/messages', desc: '会话消息' },
{ method: 'GET', path: '/v1/contacts', desc: '联系人列表' }
{ method: 'GET', path: '/v1/contacts', desc: '联系人列表' },
{ method: 'GET', path: '/v1/sns', desc: '朋友圈时间线' }
],
lastError: this.startError
}
@@ -493,6 +495,10 @@ class HttpApiService {
this.sendRedirect(res, '/v1/contacts')
return
}
if (pathname === '/api/v1/sns') {
this.sendRedirect(res, '/v1/sns')
return
}
if (pathname === '/') {
this.sendRedirect(res, '/v1')
return
@@ -1219,6 +1225,32 @@ class HttpApiService {
return
}
if (pathname === '/v1/sns') {
try {
const usernames = (url.searchParams.get('usernames') || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean)
const payload = await querySnsTimeline({
limit: this.parseIntInRange(url.searchParams.get('limit'), 20, 1, 200),
offset: this.parseIntInRange(url.searchParams.get('offset'), 0, 0, 100000),
usernames: usernames.length > 0 ? usernames : null,
keyword: (url.searchParams.get('keyword') || '').trim() || undefined,
startTime: this.parseTimestampMs(url.searchParams.get('startTime')),
endTime: this.parseTimestampMs(url.searchParams.get('endTime')),
includeRaw: this.parseBoolean(url.searchParams.get('includeRaw'), false)
})
this.sendJson(res, 200, this.success(requestId, payload))
} catch (error: any) {
const statusCode = Number(error?.statusCode) || 500
const code = String(error?.code || 'INTERNAL_ERROR')
const message = String(error?.message || 'Failed to read moments timeline')
const hint = typeof error?.hint === 'string' ? error.hint : 'Try GET /v1 or /v1/status to inspect API availability, or use /v1/sns with valid query params.'
this.sendJson(res, statusCode, this.failure(requestId, code, message, hint))
}
return
}
this.sendJson(
res,
404,
@@ -1226,7 +1258,7 @@ class HttpApiService {
requestId,
'NOT_FOUND',
'Route not found',
'Try GET /v1 for API overview, or use /v1/health and /v1/status'
'Try GET /v1 for API overview, or use /v1/health, /v1/status, /v1/messages, /v1/contacts, /v1/sessions, /v1/sns'
)
)
}

View File

@@ -23,6 +23,10 @@ export async function executeMcpTool(
const payload = getMcpStatusPayload()
return { summary: 'CipherTalk MCP status loaded.', payload }
}
case 'get_moments_timeline': {
const payload = await readService.getMomentsTimeline(args as any)
return { summary: `Loaded ${payload.items.length} moments posts.`, payload }
}
case 'resolve_session': {
const payload = await readService.resolveSession(args as any, reporter)
return {

View File

@@ -7,6 +7,7 @@ import { chatService, type ChatSession, type ContactInfo, type Message } from '.
import { ConfigService } from '../config'
import { exportService, type ExportOptions as ExportServiceOptions } from '../exportService'
import { imageDecryptService } from '../imageDecryptService'
import { snsService } from '../snsService'
import { videoService } from '../videoService'
import { McpToolError } from './result'
import {
@@ -25,6 +26,8 @@ import {
type McpContactRankingItem,
type McpContactRankingsPayload,
type McpActivityDistributionPayload,
type McpMomentItem,
type McpMomentsTimelinePayload,
type McpMessageItem,
type McpMessageKind,
type McpMessageMatchField,
@@ -129,6 +132,16 @@ const analyticsTimeRangeArgsSchema = z.object({
endTime: z.number().int().positive().optional()
})
const getMomentsTimelineArgsSchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
usernames: z.array(z.string().trim().min(1)).optional(),
keyword: z.string().optional(),
startTime: z.number().int().positive().optional(),
endTime: z.number().int().positive().optional(),
includeRaw: z.boolean().optional()
})
const contactRankingsArgsSchema = analyticsTimeRangeArgsSchema.extend({
limit: z.number().int().positive().optional()
})
@@ -163,6 +176,7 @@ type ExportChatArgs = z.infer<typeof exportChatArgsSchema>
type GetMessagesArgs = z.infer<typeof getMessagesArgsSchema>
type ListContactsArgs = z.infer<typeof listContactsArgsSchema>
type SearchMessagesArgs = z.infer<typeof searchMessagesArgsSchema>
type GetMomentsTimelineArgs = z.infer<typeof getMomentsTimelineArgsSchema>
type GetSessionContextArgs = z.infer<typeof getSessionContextArgsSchema>
type ContactWithLastContact = ContactInfo & { lastContactTime?: number }
type MessageNormalizeOptions = {
@@ -667,6 +681,82 @@ function buildSearchSessionSummaries(hits: McpSearchHit[]): McpSearchMessagesPay
.sort((a, b) => b.hitCount - a.hitCount || b.topScore - a.topScore)
}
function toMomentItem(raw: any, includeRaw: boolean): McpMomentItem {
return {
id: String(raw.id || ''),
username: String(raw.username || ''),
nickname: String(raw.nickname || raw.username || ''),
avatarUrl: raw.avatarUrl || undefined,
createTime: Number(raw.createTime || 0),
createTimeMs: toTimestampMs(Number(raw.createTime || 0)),
contentDesc: String(raw.contentDesc || ''),
type: raw.type !== undefined ? Number(raw.type) : undefined,
media: Array.isArray(raw.media) ? raw.media.map((item: any) => ({
url: String(item.url || ''),
thumb: String(item.thumb || ''),
md5: item.md5 || undefined,
token: item.token || undefined,
key: item.key || undefined,
thumbKey: item.thumbKey || undefined,
encIdx: item.encIdx || undefined,
width: item.width !== undefined ? Number(item.width) : undefined,
height: item.height !== undefined ? Number(item.height) : undefined,
livePhoto: item.livePhoto ? {
url: String(item.livePhoto.url || ''),
thumb: String(item.livePhoto.thumb || ''),
md5: item.livePhoto.md5 || undefined,
token: item.livePhoto.token || undefined,
key: item.livePhoto.key || undefined,
encIdx: item.livePhoto.encIdx || undefined
} : undefined
})) : [],
shareInfo: raw.shareInfo ? {
title: String(raw.shareInfo.title || ''),
description: String(raw.shareInfo.description || ''),
contentUrl: String(raw.shareInfo.contentUrl || ''),
thumbUrl: String(raw.shareInfo.thumbUrl || ''),
thumbKey: raw.shareInfo.thumbKey || undefined,
thumbToken: raw.shareInfo.thumbToken || undefined,
appName: raw.shareInfo.appName || undefined,
type: raw.shareInfo.type !== undefined ? Number(raw.shareInfo.type) : undefined
} : undefined,
likes: Array.isArray(raw.likes) ? raw.likes.map((item: any) => String(item || '')).filter(Boolean) : [],
comments: Array.isArray(raw.comments) ? raw.comments.map((item: any) => ({
id: String(item.id || ''),
nickname: String(item.nickname || ''),
content: String(item.content || ''),
refCommentId: String(item.refCommentId || ''),
refNickname: item.refNickname || undefined,
emojis: Array.isArray(item.emojis) ? item.emojis.map((emoji: any) => ({
url: String(emoji.url || ''),
md5: String(emoji.md5 || ''),
width: Number(emoji.width || 0),
height: Number(emoji.height || 0),
encryptUrl: emoji.encryptUrl || undefined,
aesKey: emoji.aesKey || undefined
})) : [],
images: Array.isArray(item.images) ? item.images.map((image: any) => ({
url: String(image.url || ''),
token: image.token || undefined,
key: image.key || undefined,
encIdx: image.encIdx || undefined,
thumbUrl: image.thumbUrl || undefined,
thumbUrlToken: image.thumbUrlToken || undefined,
thumbKey: image.thumbKey || undefined,
thumbEncIdx: image.thumbEncIdx || undefined,
width: image.width !== undefined ? Number(image.width) : undefined,
height: image.height !== undefined ? Number(image.height) : undefined,
heightPercentage: image.heightPercentage !== undefined ? Number(image.heightPercentage) : undefined,
fileSize: image.fileSize !== undefined ? Number(image.fileSize) : undefined,
minArea: image.minArea !== undefined ? Number(image.minArea) : undefined,
mediaId: image.mediaId || undefined,
md5: image.md5 || undefined
})) : []
})) : [],
rawXml: includeRaw ? (raw.rawXml ? String(raw.rawXml) : undefined) : undefined
}
}
function getDefaultExportPath(): string | null {
const config = new ConfigService()
try {
@@ -1567,6 +1657,40 @@ export class McpReadService {
}
}
async getMomentsTimeline(rawArgs: GetMomentsTimelineArgs): Promise<McpMomentsTimelinePayload> {
const args = getMomentsTimelineArgsSchema.safeParse(rawArgs)
if (!args.success) {
throw new McpToolError('BAD_REQUEST', 'Invalid get_moments_timeline arguments.', args.error.message)
}
const limit = Math.min(args.data.limit ?? 20, MAX_LIST_LIMIT)
const offset = Math.max(0, args.data.offset ?? 0)
const includeRaw = args.data.includeRaw ?? false
const result = await snsService.getTimeline(
limit,
offset,
args.data.usernames,
args.data.keyword,
args.data.startTime,
args.data.endTime
)
if (!result.success) {
if (String(result.error || '').includes('请先')) {
throw new McpToolError('DB_NOT_READY', result.error || '朋友圈数据库未就绪。')
}
throw new McpToolError('INTERNAL_ERROR', result.error || 'Failed to load moments timeline.')
}
const rawItems = result.timeline || []
return {
items: rawItems.map((item) => toMomentItem(item, includeRaw)),
offset,
limit,
hasMore: rawItems.length >= limit
}
}
async getMessages(rawArgs: GetMessagesArgs, defaultIncludeMediaPaths: boolean, reporter?: McpStreamReporter): Promise<McpMessagesPayload> {
const args = getMessagesArgsSchema.safeParse(rawArgs)
if (!args.success) {

View File

@@ -10,6 +10,7 @@ import type {
McpExportChatPayload,
McpGlobalStatisticsPayload,
McpHealthPayload,
McpMomentsTimelinePayload,
McpMessagesPayload,
McpResolveSessionPayload,
McpStreamEvent,
@@ -267,6 +268,10 @@ export class McpReadService {
return this.callProxy<McpStatusPayload>('get_status')
}
async getMomentsTimeline(rawArgs: Record<string, unknown>): Promise<McpMomentsTimelinePayload> {
return this.callProxy<McpMomentsTimelinePayload>('get_moments_timeline', rawArgs)
}
async resolveSession(rawArgs: Record<string, unknown>): Promise<McpResolveSessionPayload> {
return this.callProxy<McpResolveSessionPayload>('resolve_session', rawArgs)
}

View File

@@ -31,6 +31,27 @@ export function registerCipherTalkMcpTools(server: any) {
}
})
server.registerTool('get_moments_timeline', {
title: 'Get Moments Timeline',
description: 'Return structured Moments timeline posts with media, likes, comments, and share information.',
inputSchema: {
limit: z.number().int().positive().optional().describe('Pagination limit. Defaults to 20.'),
offset: z.number().int().nonnegative().optional().describe('Pagination offset. Defaults to 0.'),
usernames: z.array(z.string().trim().min(1)).optional().describe('Optional username filters.'),
keyword: z.string().optional().describe('Optional keyword filter.'),
startTime: z.number().int().positive().optional().describe('Optional start timestamp in seconds or milliseconds.'),
endTime: z.number().int().positive().optional().describe('Optional end timestamp in seconds or milliseconds.'),
includeRaw: z.boolean().optional().describe('Include raw XML when true.')
}
}, async (args: unknown) => {
try {
const payload = await readService.getMomentsTimeline((args || {}) as any)
return createToolSuccess(`Loaded ${payload.items.length} moments posts.`, payload)
} catch (error) {
return createToolError(error)
}
})
server.registerTool('resolve_session', {
title: 'Resolve Session',
description: 'Resolve a fuzzy person/session clue into the most likely chat session, returning candidates, confidence, and recommended next action.',

View File

@@ -1,6 +1,7 @@
export const MCP_TOOL_NAMES = [
'health_check',
'get_status',
'get_moments_timeline',
'resolve_session',
'export_chat',
'list_sessions',
@@ -117,6 +118,99 @@ export interface McpStatusPayload {
warnings: string[]
}
export interface McpMomentLivePhoto {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
}
export interface McpMomentMedia {
url: string
thumb: string
md5?: string
token?: string
key?: string
thumbKey?: string
encIdx?: string
livePhoto?: McpMomentLivePhoto
width?: number
height?: number
}
export interface McpMomentShareInfo {
title: string
description: string
contentUrl: string
thumbUrl: string
thumbKey?: string
thumbToken?: string
appName?: string
type?: number
}
export interface McpMomentCommentEmoji {
url: string
md5: string
width: number
height: number
encryptUrl?: string
aesKey?: string
}
export interface McpMomentCommentImage {
url: string
token?: string
key?: string
encIdx?: string
thumbUrl?: string
thumbUrlToken?: string
thumbKey?: string
thumbEncIdx?: string
width?: number
height?: number
heightPercentage?: number
fileSize?: number
minArea?: number
mediaId?: string
md5?: string
}
export interface McpMomentComment {
id: string
nickname: string
content: string
refCommentId: string
refNickname?: string
emojis?: McpMomentCommentEmoji[]
images?: McpMomentCommentImage[]
}
export interface McpMomentItem {
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
createTimeMs: number
contentDesc: string
type?: number
media: McpMomentMedia[]
shareInfo?: McpMomentShareInfo
likes: string[]
comments: McpMomentComment[]
rawXml?: string
}
export interface McpMomentsTimelinePayload {
items: McpMomentItem[]
offset: number
limit: number
hasMore: boolean
}
export interface McpSessionRef {
sessionId: string
displayName: string