mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-03 15:08:25 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
@@ -128,7 +128,7 @@ export class ConfigService {
|
||||
httpApiToken: '',
|
||||
httpApiEnabled: false,
|
||||
httpApiPort: 5031,
|
||||
httpApiHost: '127.0.0.1',
|
||||
httpApiHost: '0.0.0.0',
|
||||
messagePushEnabled: false,
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ConfigService } from './config'
|
||||
import { videoService } from './videoService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||
import { snsService } from './snsService'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
@@ -308,7 +309,7 @@ class HttpService {
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -348,6 +349,33 @@ class HttpService {
|
||||
await this.handleContacts(url, res)
|
||||
} else if (pathname === '/api/v1/group-members') {
|
||||
await this.handleGroupMembers(url, res)
|
||||
} else if (pathname === '/api/v1/sns/timeline') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsTimeline(url, res)
|
||||
} else if (pathname === '/api/v1/sns/usernames') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsUsernames(res)
|
||||
} else if (pathname === '/api/v1/sns/export/stats') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsExportStats(url, res)
|
||||
} else if (pathname === '/api/v1/sns/media/proxy') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsMediaProxy(url, res)
|
||||
} else if (pathname === '/api/v1/sns/export') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsExport(url, res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/status') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsBlockDeleteStatus(res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/install') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsBlockDeleteInstall(res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/uninstall') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsBlockDeleteUninstall(res)
|
||||
} else if (pathname.startsWith('/api/v1/sns/post/')) {
|
||||
if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE')
|
||||
await this.handleSnsDeletePost(pathname, res)
|
||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||
this.handleMediaRequest(pathname, res)
|
||||
} else {
|
||||
@@ -559,6 +587,15 @@ class HttpService {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private parseStringListParam(value: string | null): string[] | undefined {
|
||||
if (!value) return undefined
|
||||
const values = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
return values.length > 0 ? Array.from(new Set(values)) : undefined
|
||||
}
|
||||
|
||||
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||
if (!mediaEnabled) {
|
||||
@@ -790,6 +827,313 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200)
|
||||
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim() || undefined
|
||||
const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true)
|
||||
const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false)
|
||||
const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true)
|
||||
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||
const startTime = startTimeRaw > 0 ? startTimeRaw : undefined
|
||||
const endTime = endTimeRaw > 0 ? endTimeRaw : undefined
|
||||
|
||||
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns timeline')
|
||||
return
|
||||
}
|
||||
|
||||
let timeline = result.timeline || []
|
||||
if (resolveMedia && timeline.length > 0) {
|
||||
timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia)
|
||||
}
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: timeline.length,
|
||||
timeline
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSnsUsernames(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.getSnsUsernames()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns usernames')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
usernames: result.usernames || []
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const fast = this.parseBooleanParam(url, ['fast'], false)
|
||||
const result = fast
|
||||
? await snsService.getExportStatsFast()
|
||||
: await snsService.getExportStats()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns export stats')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const mediaUrl = (url.searchParams.get('url') || '').trim()
|
||||
if (!mediaUrl) {
|
||||
this.sendError(res, 400, 'Missing required parameter: url')
|
||||
return
|
||||
}
|
||||
|
||||
const key = this.toSnsMediaKey(url.searchParams.get('key'))
|
||||
const result = await snsService.downloadImage(mediaUrl, key)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||
res.setHeader('Content-Length', result.data.length)
|
||||
res.writeHead(200)
|
||||
res.end(result.data)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.cachePath && fs.existsSync(result.cachePath)) {
|
||||
try {
|
||||
const stat = fs.statSync(result.cachePath)
|
||||
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||
res.setHeader('Content-Length', stat.size)
|
||||
res.writeHead(200)
|
||||
|
||||
const stream = fs.createReadStream(result.cachePath)
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
this.sendError(res, 500, 'Failed to read proxied sns media')
|
||||
} else {
|
||||
try { res.destroy() } catch {}
|
||||
}
|
||||
})
|
||||
stream.pipe(res)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Failed to stream sns media cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||
}
|
||||
|
||||
private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const outputDir = String(url.searchParams.get('outputDir') || '').trim()
|
||||
if (!outputDir) {
|
||||
this.sendError(res, 400, 'Missing required field: outputDir')
|
||||
return
|
||||
}
|
||||
|
||||
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
|
||||
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
|
||||
if (!['json', 'html', 'arkmejson'].includes(format)) {
|
||||
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
|
||||
return
|
||||
}
|
||||
|
||||
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||
const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined
|
||||
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||
|
||||
const options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
exportImages?: boolean
|
||||
exportLivePhotos?: boolean
|
||||
exportVideos?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
} = {
|
||||
outputDir,
|
||||
format: format as 'json' | 'html' | 'arkmejson',
|
||||
usernames,
|
||||
keyword,
|
||||
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
|
||||
}
|
||||
|
||||
if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false)
|
||||
if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false)
|
||||
if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false)
|
||||
if (startTimeRaw > 0) options.startTime = startTimeRaw
|
||||
if (endTimeRaw > 0) options.endTime = endTimeRaw
|
||||
|
||||
const result = await snsService.exportTimeline(options)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to export sns timeline')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.checkSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to check sns block-delete status')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.installSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.uninstallSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> {
|
||||
const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim()
|
||||
if (!postId) {
|
||||
this.sendError(res, 400, 'Missing required path parameter: postId')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await snsService.deleteSnsPost(postId)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to delete sns post')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private toSnsMediaKey(value: unknown): string | number | undefined {
|
||||
if (value == null) return undefined
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
const text = String(value).trim()
|
||||
if (!text) return undefined
|
||||
if (/^-?\d+$/.test(text)) return Number(text)
|
||||
return text
|
||||
}
|
||||
|
||||
private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined {
|
||||
const target = String(rawUrl || '').trim()
|
||||
if (!target) return undefined
|
||||
const params = new URLSearchParams({ url: target })
|
||||
if (key !== undefined) params.set('key', String(key))
|
||||
return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}`
|
||||
}
|
||||
|
||||
private async resolveSnsMediaUrl(
|
||||
rawUrl: string,
|
||||
key: string | number | undefined,
|
||||
inline: boolean
|
||||
): Promise<{ resolvedUrl?: string; proxyUrl?: string }> {
|
||||
const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key)
|
||||
if (!proxyUrl) return {}
|
||||
if (!inline) return { resolvedUrl: proxyUrl, proxyUrl }
|
||||
|
||||
try {
|
||||
const resolved = await snsService.proxyImage(rawUrl, key)
|
||||
if (resolved.success && resolved.dataUrl) {
|
||||
return { resolvedUrl: resolved.dataUrl, proxyUrl }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error)
|
||||
}
|
||||
|
||||
return { resolvedUrl: proxyUrl, proxyUrl }
|
||||
}
|
||||
|
||||
private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> {
|
||||
return Promise.all(
|
||||
(posts || []).map(async (post) => {
|
||||
const mediaList = Array.isArray(post?.media) ? post.media : []
|
||||
if (mediaList.length === 0) return post
|
||||
|
||||
const nextMedia = await Promise.all(
|
||||
mediaList.map(async (media: any) => {
|
||||
const rawUrl = typeof media?.url === 'string' ? media.url : ''
|
||||
const rawThumb = typeof media?.thumb === 'string' ? media.thumb : ''
|
||||
const mediaKey = this.toSnsMediaKey(media?.key)
|
||||
|
||||
const [urlResolved, thumbResolved] = await Promise.all([
|
||||
this.resolveSnsMediaUrl(rawUrl, mediaKey, inline),
|
||||
this.resolveSnsMediaUrl(rawThumb, mediaKey, inline)
|
||||
])
|
||||
|
||||
const nextItem: any = {
|
||||
...media,
|
||||
rawUrl,
|
||||
rawThumb,
|
||||
resolvedUrl: urlResolved.resolvedUrl,
|
||||
resolvedThumbUrl: thumbResolved.resolvedUrl,
|
||||
proxyUrl: urlResolved.proxyUrl,
|
||||
proxyThumbUrl: thumbResolved.proxyUrl
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
nextItem.url = urlResolved.resolvedUrl || rawUrl
|
||||
nextItem.thumb = thumbResolved.resolvedUrl || rawThumb
|
||||
}
|
||||
|
||||
if (media?.livePhoto && typeof media.livePhoto === 'object') {
|
||||
const livePhoto = media.livePhoto
|
||||
const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : ''
|
||||
const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : ''
|
||||
const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey)
|
||||
|
||||
const [liveUrlResolved, liveThumbResolved] = await Promise.all([
|
||||
this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline),
|
||||
this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline)
|
||||
])
|
||||
|
||||
const nextLive: any = {
|
||||
...livePhoto,
|
||||
rawUrl: rawLiveUrl,
|
||||
rawThumb: rawLiveThumb,
|
||||
resolvedUrl: liveUrlResolved.resolvedUrl,
|
||||
resolvedThumbUrl: liveThumbResolved.resolvedUrl,
|
||||
proxyUrl: liveUrlResolved.proxyUrl,
|
||||
proxyThumbUrl: liveThumbResolved.proxyUrl
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl
|
||||
nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb
|
||||
}
|
||||
|
||||
nextItem.livePhoto = nextLive
|
||||
}
|
||||
|
||||
return nextItem
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
...post,
|
||||
media: nextMedia
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private getApiMediaExportPath(): string {
|
||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||
}
|
||||
@@ -1451,6 +1795,11 @@ class HttpService {
|
||||
res.end(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void {
|
||||
res.setHeader('Allow', allow)
|
||||
this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*/
|
||||
|
||||
@@ -537,6 +537,32 @@ class SnsService {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
let offset = 0
|
||||
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||||
if (!result.success || !Array.isArray(result.timeline)) {
|
||||
throw new Error(result.error || '获取朋友圈发布者失败')
|
||||
}
|
||||
|
||||
const rows = result.timeline
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
const username = this.pickTimelineUsername(row)
|
||||
if (username) uniqueUsers.add(username)
|
||||
}
|
||||
|
||||
if (rows.length < pageSize) break
|
||||
offset += rows.length
|
||||
}
|
||||
|
||||
return Array.from(uniqueUsers)
|
||||
}
|
||||
|
||||
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
@@ -794,7 +820,22 @@ class SnsService {
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
return { success: true, usernames: result.usernames || [] }
|
||||
const directUsernames = Array.isArray(result.usernames) ? result.usernames : []
|
||||
if (directUsernames.length > 0) {
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
// 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。
|
||||
try {
|
||||
const timelineUsers = await this.collectSnsUsernamesFromTimeline()
|
||||
if (timelineUsers.length > 0) {
|
||||
return { success: true, usernames: timelineUsers }
|
||||
}
|
||||
} catch {
|
||||
// 忽略回退错误,保持与原行为一致返回空数组
|
||||
}
|
||||
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
@@ -1199,7 +1240,7 @@ class SnsService {
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||
return this.fetchAndDecryptImage(url, key)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user