From 69d875f788eac429cf0a1f06bd6df38628c4070a Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Tue, 7 Apr 2026 14:45:08 +0800 Subject: [PATCH] feat: add moments data to mcp and http api --- README.md | 20 +++++ electron/services/httpApiFacade.ts | 74 ++++++++++++++++ electron/services/httpApiService.ts | 36 +++++++- electron/services/mcp/dispatcher.ts | 4 + electron/services/mcp/readService.ts | 124 +++++++++++++++++++++++++++ electron/services/mcp/service.ts | 5 ++ electron/services/mcp/tools.ts | 21 +++++ electron/services/mcp/types.ts | 94 ++++++++++++++++++++ 8 files changed, 376 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cdb2d8d..38b9470 100644 --- a/README.md +++ b/README.md @@ -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 + } +} +``` + +第一版返回朋友圈结构化时间线,不包含媒体下载或本地路径解析接口。 + --- ## 💻 开发指南 diff --git a/electron/services/httpApiFacade.ts b/electron/services/httpApiFacade.ts index 4d1ca9a..eadcf7e 100644 --- a/electron/services/httpApiFacade.ts +++ b/electron/services/httpApiFacade.ts @@ -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 + } +} diff --git a/electron/services/httpApiService.ts b/electron/services/httpApiService.ts index 5fff8b5..8d072e0 100644 --- a/electron/services/httpApiService.ts +++ b/electron/services/httpApiService.ts @@ -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' ) ) } diff --git a/electron/services/mcp/dispatcher.ts b/electron/services/mcp/dispatcher.ts index 8fe3fa0..ed7b40f 100644 --- a/electron/services/mcp/dispatcher.ts +++ b/electron/services/mcp/dispatcher.ts @@ -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 { diff --git a/electron/services/mcp/readService.ts b/electron/services/mcp/readService.ts index afee941..e4a05e7 100644 --- a/electron/services/mcp/readService.ts +++ b/electron/services/mcp/readService.ts @@ -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 type GetMessagesArgs = z.infer type ListContactsArgs = z.infer type SearchMessagesArgs = z.infer +type GetMomentsTimelineArgs = z.infer type GetSessionContextArgs = z.infer 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 { + 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 { const args = getMessagesArgsSchema.safeParse(rawArgs) if (!args.success) { diff --git a/electron/services/mcp/service.ts b/electron/services/mcp/service.ts index b1e15ba..66c189f 100644 --- a/electron/services/mcp/service.ts +++ b/electron/services/mcp/service.ts @@ -10,6 +10,7 @@ import type { McpExportChatPayload, McpGlobalStatisticsPayload, McpHealthPayload, + McpMomentsTimelinePayload, McpMessagesPayload, McpResolveSessionPayload, McpStreamEvent, @@ -267,6 +268,10 @@ export class McpReadService { return this.callProxy('get_status') } + async getMomentsTimeline(rawArgs: Record): Promise { + return this.callProxy('get_moments_timeline', rawArgs) + } + async resolveSession(rawArgs: Record): Promise { return this.callProxy('resolve_session', rawArgs) } diff --git a/electron/services/mcp/tools.ts b/electron/services/mcp/tools.ts index 393fe8d..ece738e 100644 --- a/electron/services/mcp/tools.ts +++ b/electron/services/mcp/tools.ts @@ -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.', diff --git a/electron/services/mcp/types.ts b/electron/services/mcp/types.ts index c2f243e..0198d84 100644 --- a/electron/services/mcp/types.ts +++ b/electron/services/mcp/types.ts @@ -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