mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-30 13:51:50 +08:00
feat: add moments data to mcp and http api
This commit is contained in:
20
README.md
20
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
第一版返回朋友圈结构化时间线,不包含媒体下载或本地路径解析接口。
|
||||
|
||||
---
|
||||
|
||||
## 💻 开发指南
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user