feat: add dashboard friend circle management
This commit is contained in:
390
admin/dashboard/blueprints/friend_circle.py
Normal file
390
admin/dashboard/blueprints/friend_circle.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import asyncio
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .auth import login_required
|
||||||
|
|
||||||
|
friend_circle_bp = Blueprint("friend_circle", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_client_coro(server, coro, timeout=90):
|
||||||
|
future = asyncio.run_coroutine_threadsafe(coro, server.robot.ipad_loop)
|
||||||
|
return future.result(timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_text(value, default=""):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if "string" in value:
|
||||||
|
return _safe_text(value.get("string"), default)
|
||||||
|
if "text" in value:
|
||||||
|
return _safe_text(value.get("text"), default)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_nodes(payload):
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
yield payload
|
||||||
|
for value in payload.values():
|
||||||
|
yield from _walk_nodes(value)
|
||||||
|
elif isinstance(payload, list):
|
||||||
|
for item in payload:
|
||||||
|
yield from _walk_nodes(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_list(values):
|
||||||
|
if isinstance(values, list):
|
||||||
|
return values
|
||||||
|
if isinstance(values, dict):
|
||||||
|
for key in ("list", "List", "items", "Items"):
|
||||||
|
if isinstance(values.get(key), list):
|
||||||
|
return values.get(key)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_media_from_xml(root):
|
||||||
|
media_items = []
|
||||||
|
for media in root.findall(".//mediaList/media"):
|
||||||
|
media_items.append({
|
||||||
|
"url": _safe_text(media.findtext("url")),
|
||||||
|
"thumb": _safe_text(media.findtext("thumb")),
|
||||||
|
"id": _safe_text(media.findtext("id"))
|
||||||
|
})
|
||||||
|
return [item for item in media_items if item.get("url") or item.get("thumb")]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timeline_xml(xml_text):
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
except ET.ParseError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
create_time = _safe_text(root.findtext("createTime"))
|
||||||
|
timestamp = ""
|
||||||
|
if create_time.isdigit():
|
||||||
|
timestamp = datetime.fromtimestamp(int(create_time)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": _safe_text(root.findtext("id")),
|
||||||
|
"author_wxid": _safe_text(root.findtext("username")),
|
||||||
|
"content": _safe_text(root.findtext("contentDesc")),
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"media": _parse_media_from_xml(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_comment(comment):
|
||||||
|
comment_id = (
|
||||||
|
_safe_text(comment.get("CommnetId"))
|
||||||
|
or _safe_text(comment.get("CommentId"))
|
||||||
|
or _safe_text(comment.get("id"))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"comment_id": comment_id,
|
||||||
|
"author_wxid": (
|
||||||
|
_safe_text(comment.get("UserName"))
|
||||||
|
or _safe_text(comment.get("username"))
|
||||||
|
or _safe_text(comment.get("Wxid"))
|
||||||
|
),
|
||||||
|
"author_name": (
|
||||||
|
_safe_text(comment.get("NickName"))
|
||||||
|
or _safe_text(comment.get("nickname"))
|
||||||
|
or _safe_text(comment.get("DisplayName"))
|
||||||
|
),
|
||||||
|
"content": (
|
||||||
|
_safe_text(comment.get("Content"))
|
||||||
|
or _safe_text(comment.get("content"))
|
||||||
|
or _safe_text(comment.get("Text"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_like(like):
|
||||||
|
return {
|
||||||
|
"author_wxid": (
|
||||||
|
_safe_text(like.get("UserName"))
|
||||||
|
or _safe_text(like.get("username"))
|
||||||
|
or _safe_text(like.get("Wxid"))
|
||||||
|
),
|
||||||
|
"author_name": (
|
||||||
|
_safe_text(like.get("NickName"))
|
||||||
|
or _safe_text(like.get("nickname"))
|
||||||
|
or _safe_text(like.get("DisplayName"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_post(node, server):
|
||||||
|
xml_payload = ""
|
||||||
|
for key in ("Content", "Xml", "ObjectDesc", "TimelineObject"):
|
||||||
|
value = node.get(key)
|
||||||
|
if isinstance(value, str) and "<TimelineObject" in value:
|
||||||
|
xml_payload = value
|
||||||
|
break
|
||||||
|
|
||||||
|
xml_data = _parse_timeline_xml(xml_payload) if xml_payload else {}
|
||||||
|
object_id = (
|
||||||
|
_safe_text(node.get("Id"))
|
||||||
|
or _safe_text(node.get("id"))
|
||||||
|
or xml_data.get("id", "")
|
||||||
|
)
|
||||||
|
author_wxid = (
|
||||||
|
_safe_text(node.get("UserName"))
|
||||||
|
or _safe_text(node.get("Username"))
|
||||||
|
or _safe_text(node.get("Towxid"))
|
||||||
|
or xml_data.get("author_wxid", "")
|
||||||
|
)
|
||||||
|
author_name = (
|
||||||
|
_safe_text(node.get("NickName"))
|
||||||
|
or _safe_text(node.get("nickname"))
|
||||||
|
or server.contact_manager.get_nickname(author_wxid)
|
||||||
|
)
|
||||||
|
content = (
|
||||||
|
_safe_text(node.get("ContentDesc"))
|
||||||
|
or _safe_text(node.get("contentDesc"))
|
||||||
|
or _safe_text(node.get("Content"))
|
||||||
|
)
|
||||||
|
if "<TimelineObject" in content:
|
||||||
|
content = ""
|
||||||
|
content = content or xml_data.get("content", "")
|
||||||
|
|
||||||
|
create_time = (
|
||||||
|
_safe_text(node.get("CreateTime"))
|
||||||
|
or _safe_text(node.get("createTime"))
|
||||||
|
)
|
||||||
|
timestamp = xml_data.get("timestamp", "")
|
||||||
|
if not timestamp and create_time.isdigit():
|
||||||
|
timestamp = datetime.fromtimestamp(int(create_time)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
media = []
|
||||||
|
for list_key in ("MediaList", "mediaList", "Medias", "medias"):
|
||||||
|
media_list = _extract_list(node.get(list_key))
|
||||||
|
for item in media_list:
|
||||||
|
media.append({
|
||||||
|
"url": _safe_text(item.get("Url")) or _safe_text(item.get("url")),
|
||||||
|
"thumb": _safe_text(item.get("ThumbUrl")) or _safe_text(item.get("thumb")),
|
||||||
|
"id": _safe_text(item.get("Id")) or _safe_text(item.get("id"))
|
||||||
|
})
|
||||||
|
if not media:
|
||||||
|
media = xml_data.get("media", [])
|
||||||
|
|
||||||
|
comment_nodes = []
|
||||||
|
like_nodes = []
|
||||||
|
for key in ("CommentList", "Comments", "commentList", "comments"):
|
||||||
|
comment_nodes.extend(_extract_list(node.get(key)))
|
||||||
|
for key in ("LikeList", "Likes", "likeList", "likes"):
|
||||||
|
like_nodes.extend(_extract_list(node.get(key)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": object_id,
|
||||||
|
"author_wxid": author_wxid,
|
||||||
|
"author_name": author_name,
|
||||||
|
"author_avatar": server.contact_manager.get_head_image(author_wxid),
|
||||||
|
"content": content,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"media": [item for item in media if item.get("url") or item.get("thumb")],
|
||||||
|
"comments": [item for item in (_normalize_comment(x) for x in comment_nodes) if item.get("content") or item.get("author_wxid")],
|
||||||
|
"likes": [item for item in (_normalize_like(x) for x in like_nodes) if item.get("author_wxid")],
|
||||||
|
"raw": node
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_posts(payload, server):
|
||||||
|
posts = []
|
||||||
|
seen_ids = set()
|
||||||
|
for node in _walk_nodes(payload):
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
continue
|
||||||
|
looks_like_post = any(key in node for key in ("Id", "id", "ContentDesc", "CommentList", "LikeList", "ObjectDesc"))
|
||||||
|
has_timeline_xml = any(isinstance(value, str) and "<TimelineObject" in value for value in node.values())
|
||||||
|
if not looks_like_post and not has_timeline_xml:
|
||||||
|
continue
|
||||||
|
post = _normalize_post(node, server)
|
||||||
|
if not post.get("id"):
|
||||||
|
continue
|
||||||
|
if post["id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(post["id"])
|
||||||
|
posts.append(post)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
@friend_circle_bp.route("/friend_circle")
|
||||||
|
@login_required
|
||||||
|
def friend_circle_page():
|
||||||
|
return render_template("friend_circle.html")
|
||||||
|
|
||||||
|
|
||||||
|
@friend_circle_bp.route("/api/friend_circle/contacts")
|
||||||
|
@login_required
|
||||||
|
def friend_circle_contacts():
|
||||||
|
server = current_app.dashboard_server
|
||||||
|
keyword = request.args.get("keyword", "").strip().lower()
|
||||||
|
result = []
|
||||||
|
for wxid, nickname in server.contact_manager.get_personal_contacts().items():
|
||||||
|
if keyword and keyword not in wxid.lower() and keyword not in str(nickname).lower():
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"wxid": wxid,
|
||||||
|
"nickname": nickname,
|
||||||
|
"avatar": server.contact_manager.get_head_image(wxid)
|
||||||
|
})
|
||||||
|
if len(result) >= 50:
|
||||||
|
break
|
||||||
|
return jsonify({"success": True, "data": result})
|
||||||
|
|
||||||
|
|
||||||
|
@friend_circle_bp.route("/api/friend_circle/list")
|
||||||
|
@login_required
|
||||||
|
def friend_circle_list():
|
||||||
|
server = current_app.dashboard_server
|
||||||
|
towxid = request.args.get("towxid", "").strip()
|
||||||
|
max_id = int(request.args.get("max_id", 0))
|
||||||
|
first_page_md5 = request.args.get("first_page_md5", "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if towxid:
|
||||||
|
payload = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.get_friend_circle_detail(towxid=towxid, max_id=max_id, first_page_md5=first_page_md5)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.get_friend_circle_list(max_id=max_id, first_page_md5=first_page_md5)
|
||||||
|
)
|
||||||
|
posts = _extract_posts(payload, server)
|
||||||
|
return jsonify({"success": True, "data": posts, "raw": payload})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"获取朋友圈列表失败: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@friend_circle_bp.route("/api/friend_circle/detail")
|
||||||
|
@login_required
|
||||||
|
def friend_circle_detail():
|
||||||
|
server = current_app.dashboard_server
|
||||||
|
object_id = request.args.get("id", "").strip()
|
||||||
|
towxid = request.args.get("towxid", "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.get_friend_circle_id_detail(object_id=object_id, towxid=towxid)
|
||||||
|
)
|
||||||
|
posts = _extract_posts(payload, server)
|
||||||
|
detail = posts[0] if posts else None
|
||||||
|
return jsonify({"success": True, "data": detail, "raw": payload})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"获取朋友圈详情失败: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@friend_circle_bp.route("/api/friend_circle/publish", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def friend_circle_publish():
|
||||||
|
server = current_app.dashboard_server
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
content = (data.get("content") or "").strip()
|
||||||
|
blacklist = (data.get("blacklist") or "").strip()
|
||||||
|
with_user_list = (data.get("with_user_list") or "").strip()
|
||||||
|
images = data.get("images") or []
|
||||||
|
|
||||||
|
if not content and not images:
|
||||||
|
return jsonify({"success": False, "message": "内容和图片不能同时为空"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_items = []
|
||||||
|
for image in images:
|
||||||
|
upload_data = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.upload_friend_circle_media(image)
|
||||||
|
)
|
||||||
|
urls = _extract_list(upload_data.get("Urls"))
|
||||||
|
thumbs = _extract_list(upload_data.get("ThumbUrls"))
|
||||||
|
media_items.append({
|
||||||
|
"id": _safe_text(upload_data.get("Id")) or _safe_text(upload_data.get("MediaId")),
|
||||||
|
"url": _safe_text(urls[0].get("Url")) if urls else "",
|
||||||
|
"thumb": _safe_text(thumbs[0].get("Url")) if thumbs else (_safe_text(urls[0].get("Url")) if urls else ""),
|
||||||
|
"md5": _safe_text(upload_data.get("Md5")),
|
||||||
|
"total_size": upload_data.get("TotalSize", 0),
|
||||||
|
"width": upload_data.get("Width", 0),
|
||||||
|
"height": upload_data.get("Height", 0)
|
||||||
|
})
|
||||||
|
payload = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.publish_friend_circle(
|
||||||
|
content=content,
|
||||||
|
media_items=media_items,
|
||||||
|
blacklist=blacklist,
|
||||||
|
with_user_list=with_user_list
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, "data": payload})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"发布朋友圈失败: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@friend_circle_bp.route("/api/friend_circle/like", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def friend_circle_like():
|
||||||
|
server = current_app.dashboard_server
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
object_id = str(data.get("id") or "").strip()
|
||||||
|
cancel_like = bool(data.get("cancel"))
|
||||||
|
|
||||||
|
if not object_id:
|
||||||
|
return jsonify({"success": False, "message": "缺少朋友圈ID"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cancel_like:
|
||||||
|
payload = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.friend_circle_operation(object_id=object_id, type=5)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.friend_circle_comment(object_id=object_id, type=1)
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, "data": payload})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"朋友圈点赞操作失败: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@friend_circle_bp.route("/api/friend_circle/comment", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def friend_circle_comment():
|
||||||
|
server = current_app.dashboard_server
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
object_id = str(data.get("id") or "").strip()
|
||||||
|
content = (data.get("content") or "").strip()
|
||||||
|
reply_comment_id = int(data.get("reply_comment_id") or 0)
|
||||||
|
|
||||||
|
if not object_id or not content:
|
||||||
|
return jsonify({"success": False, "message": "评论目标和内容不能为空"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = _run_client_coro(
|
||||||
|
server,
|
||||||
|
server.client.friend_circle_comment(
|
||||||
|
object_id=object_id,
|
||||||
|
content=content,
|
||||||
|
type=2,
|
||||||
|
reply_comment_id=reply_comment_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({"success": True, "data": payload})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"朋友圈评论失败: {exc}")
|
||||||
|
return jsonify({"success": False, "message": str(exc)}), 500
|
||||||
@@ -147,6 +147,7 @@ class DashboardServer:
|
|||||||
from admin.dashboard.blueprints.virtual_group import virtual_group_bp
|
from admin.dashboard.blueprints.virtual_group import virtual_group_bp
|
||||||
from admin.dashboard.blueprints.file_browser import file_browser_bp
|
from admin.dashboard.blueprints.file_browser import file_browser_bp
|
||||||
from admin.dashboard.blueprints.message_push import message_push_bp
|
from admin.dashboard.blueprints.message_push import message_push_bp
|
||||||
|
from admin.dashboard.blueprints.friend_circle import friend_circle_bp
|
||||||
|
|
||||||
# 在app.register_blueprint部分添加
|
# 在app.register_blueprint部分添加
|
||||||
app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group')
|
app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group')
|
||||||
@@ -160,6 +161,7 @@ class DashboardServer:
|
|||||||
app.register_blueprint(plugin_routes)
|
app.register_blueprint(plugin_routes)
|
||||||
app.register_blueprint(file_browser_bp)
|
app.register_blueprint(file_browser_bp)
|
||||||
app.register_blueprint(message_push_bp)
|
app.register_blueprint(message_push_bp)
|
||||||
|
app.register_blueprint(friend_circle_bp)
|
||||||
|
|
||||||
self.LOG.info("所有蓝图已注册")
|
self.LOG.info("所有蓝图已注册")
|
||||||
|
|
||||||
|
|||||||
@@ -666,6 +666,16 @@
|
|||||||
{ label: '错误日志', path: '/errors' }
|
{ label: '错误日志', path: '/errors' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'friend-circle',
|
||||||
|
label: '朋友圈',
|
||||||
|
icon: 'el-icon-picture-outline-round',
|
||||||
|
description: '集中管理朋友圈查看、发布、点赞与评论流程',
|
||||||
|
defaultPath: '/friend_circle',
|
||||||
|
items: [
|
||||||
|
{ label: '朋友圈管理', path: '/friend_circle' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'groups',
|
key: 'groups',
|
||||||
label: '群组',
|
label: '群组',
|
||||||
|
|||||||
456
admin/dashboard/templates/friend_circle.html
Normal file
456
admin/dashboard/templates/friend_circle.html
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}朋友圈管理{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-shell friend-circle-page">
|
||||||
|
<div class="page-hero">
|
||||||
|
<div class="page-hero-copy">
|
||||||
|
<div class="page-eyebrow">Friend Circle Workspace</div>
|
||||||
|
<h1>朋友圈管理</h1>
|
||||||
|
<p>在后台直接完成朋友圈查看、发布、点赞和评论,不再依赖聊天指令。</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-hero-actions">
|
||||||
|
<el-button @click="resetFilters">重置视图</el-button>
|
||||||
|
<el-button type="primary" @click="loadPosts">刷新列表</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="friend-circle-grid">
|
||||||
|
<div class="friend-circle-main">
|
||||||
|
<el-card class="workspace-card" shadow="hover">
|
||||||
|
<div slot="header" class="workspace-header">
|
||||||
|
<div>
|
||||||
|
<h3>查看范围</h3>
|
||||||
|
<p>支持首页列表和指定好友朋友圈切换查看。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form :inline="true" size="small" class="filter-form">
|
||||||
|
<el-form-item label="查看模式">
|
||||||
|
<el-radio-group v-model="viewMode" size="small">
|
||||||
|
<el-radio-button label="home">首页流</el-radio-button>
|
||||||
|
<el-radio-button label="user">指定好友</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="viewMode === 'user'" label="好友">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedTowxid"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
clearable
|
||||||
|
reserve-keyword
|
||||||
|
placeholder="输入昵称或wxid搜索"
|
||||||
|
:remote-method="searchContacts"
|
||||||
|
:loading="contactsLoading"
|
||||||
|
style="width: 320px;">
|
||||||
|
<el-option
|
||||||
|
v-for="item in contactOptions"
|
||||||
|
:key="item.wxid"
|
||||||
|
:label="`${item.nickname} (${item.wxid})`"
|
||||||
|
:value="item.wxid">
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="viewMode === 'user'" label="或手动输入">
|
||||||
|
<el-input v-model="manualTowxid" placeholder="wxid_xxx"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="分页游标">
|
||||||
|
<el-input-number v-model="maxId" :min="0" :step="1" controls-position="right"></el-input-number>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="loadPosts">查询</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="post-list">
|
||||||
|
<el-empty v-if="!loading && posts.length === 0" description="暂无朋友圈数据"></el-empty>
|
||||||
|
|
||||||
|
<el-card v-for="post in posts" :key="post.id" class="post-card" shadow="hover">
|
||||||
|
<div class="post-header">
|
||||||
|
<div class="post-author">
|
||||||
|
<el-avatar :src="post.author_avatar" :size="42">{{ (post.author_name || '?').slice(0, 1) }}</el-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="post-author-name">{% raw %}{{ post.author_name || post.author_wxid || '未知用户' }}{% endraw %}</div>
|
||||||
|
<div class="post-meta">{% raw %}{{ post.timestamp || '时间未知' }} · ID: {{ post.id }}{% endraw %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="post-actions">
|
||||||
|
<el-button type="text" @click="openRawDialog(post)">原始数据</el-button>
|
||||||
|
<el-button type="text" @click="openCommentDialog(post)">评论</el-button>
|
||||||
|
<el-button type="text" @click="toggleLike(post, false)">点赞</el-button>
|
||||||
|
<el-button type="text" class="danger-text" @click="toggleLike(post, true)">取消赞</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-content">{% raw %}{{ post.content || '这条朋友圈没有解析出文字内容' }}{% endraw %}</div>
|
||||||
|
|
||||||
|
<div v-if="post.media && post.media.length" class="post-media-grid">
|
||||||
|
<div v-for="(media, index) in post.media" :key="`${post.id}-${index}`" class="post-media-item">
|
||||||
|
<img :src="media.thumb || media.url" :alt="post.content || '朋友圈图片'" @click="previewImage(media)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-feedback">
|
||||||
|
<div class="feedback-block">
|
||||||
|
<div class="feedback-title">点赞 {% raw %}{{ post.likes.length }}{% endraw %}</div>
|
||||||
|
<div v-if="post.likes.length" class="feedback-list">
|
||||||
|
<span v-for="(like, index) in post.likes" :key="`${post.id}-like-${index}`" class="feedback-chip">
|
||||||
|
{% raw %}{{ like.author_name || like.author_wxid }}{% endraw %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="feedback-empty">暂无点赞</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feedback-block">
|
||||||
|
<div class="feedback-title">评论 {% raw %}{{ post.comments.length }}{% endraw %}</div>
|
||||||
|
<div v-if="post.comments.length" class="comment-list">
|
||||||
|
<div v-for="(comment, index) in post.comments" :key="`${post.id}-comment-${index}`" class="comment-item">
|
||||||
|
<span class="comment-author">{% raw %}{{ comment.author_name || comment.author_wxid || '匿名' }}{% endraw %}</span>
|
||||||
|
<span class="comment-content">{% raw %}{{ comment.content }}{% endraw %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="feedback-empty">暂无评论</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="friend-circle-side">
|
||||||
|
<el-card class="workspace-card publish-card" shadow="hover">
|
||||||
|
<div slot="header" class="workspace-header">
|
||||||
|
<div>
|
||||||
|
<h3>发布朋友圈</h3>
|
||||||
|
<p>支持纯文本,也支持上传多张图片一起发布。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-position="top" size="small">
|
||||||
|
<el-form-item label="文案">
|
||||||
|
<el-input
|
||||||
|
v-model="publishForm.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
placeholder="输入朋友圈文案">
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="可见人白名单">
|
||||||
|
<el-input v-model="publishForm.with_user_list" placeholder="多个wxid使用英文逗号分隔"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="不可见人黑名单">
|
||||||
|
<el-input v-model="publishForm.blacklist" placeholder="多个wxid使用英文逗号分隔"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="配图">
|
||||||
|
<div class="upload-toolbar">
|
||||||
|
<label class="upload-trigger">
|
||||||
|
<input type="file" accept="image/*" multiple @change="handleImageChange">
|
||||||
|
<span>选择图片</span>
|
||||||
|
</label>
|
||||||
|
<el-button type="text" @click="clearImages" v-if="publishImages.length">清空</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="publishImages.length" class="upload-preview-grid">
|
||||||
|
<div v-for="(image, index) in publishImages" :key="`upload-${index}`" class="upload-preview-item">
|
||||||
|
<img :src="image" alt="预览图">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="publishing" @click="submitPublish">发布</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog title="发表评论" :visible.sync="commentDialogVisible" width="420px">
|
||||||
|
<el-form label-position="top" size="small">
|
||||||
|
<el-form-item label="朋友圈ID">
|
||||||
|
<el-input :value="selectedPost ? selectedPost.id : ''" disabled></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="回复评论ID">
|
||||||
|
<el-input-number v-model="commentForm.reply_comment_id" :min="0" :step="1" controls-position="right"></el-input-number>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="评论内容">
|
||||||
|
<el-input v-model="commentForm.content" type="textarea" :rows="4" placeholder="输入评论内容"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<span slot="footer">
|
||||||
|
<el-button @click="commentDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="commentSubmitting" @click="submitComment">提交评论</el-button>
|
||||||
|
</span>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog title="原始返回" :visible.sync="rawDialogVisible" width="70%">
|
||||||
|
<pre class="raw-json">{% raw %}{{ rawDialogContent }}{% endraw %}</pre>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog :visible.sync="imagePreviewVisible" width="70%" class="image-preview-dialog">
|
||||||
|
<img v-if="previewImageUrl" :src="previewImageUrl" class="large-preview" alt="朋友圈图片预览">
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#app',
|
||||||
|
mixins: [baseApp],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentView: 'friend_circle',
|
||||||
|
showTimeRangeSelector: false,
|
||||||
|
loading: false,
|
||||||
|
publishing: false,
|
||||||
|
commentSubmitting: false,
|
||||||
|
contactsLoading: false,
|
||||||
|
viewMode: 'home',
|
||||||
|
selectedTowxid: '',
|
||||||
|
manualTowxid: '',
|
||||||
|
maxId: 0,
|
||||||
|
posts: [],
|
||||||
|
contactOptions: [],
|
||||||
|
publishImages: [],
|
||||||
|
publishForm: {
|
||||||
|
content: '',
|
||||||
|
blacklist: '',
|
||||||
|
with_user_list: ''
|
||||||
|
},
|
||||||
|
commentDialogVisible: false,
|
||||||
|
rawDialogVisible: false,
|
||||||
|
imagePreviewVisible: false,
|
||||||
|
previewImageUrl: '',
|
||||||
|
rawDialogContent: '',
|
||||||
|
selectedPost: null,
|
||||||
|
commentForm: {
|
||||||
|
content: '',
|
||||||
|
reply_comment_id: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadPosts();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadPosts() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
const targetWxid = this.manualTowxid.trim() || this.selectedTowxid;
|
||||||
|
if (this.viewMode === 'user' && targetWxid) {
|
||||||
|
params.towxid = targetWxid;
|
||||||
|
}
|
||||||
|
if (this.maxId) {
|
||||||
|
params.max_id = this.maxId;
|
||||||
|
}
|
||||||
|
const response = await axios.get('/api/friend_circle/list', { params });
|
||||||
|
if (response.data.success) {
|
||||||
|
this.posts = response.data.data || [];
|
||||||
|
} else {
|
||||||
|
this.$message.error(response.data.message || '加载失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error.response?.data?.message || '加载朋友圈失败');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async searchContacts(keyword) {
|
||||||
|
this.contactsLoading = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/friend_circle/contacts', {
|
||||||
|
params: { keyword }
|
||||||
|
});
|
||||||
|
this.contactOptions = response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
this.contactOptions = [];
|
||||||
|
} finally {
|
||||||
|
this.contactsLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleImageChange(event) {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
Promise.all(files.map(file => this.fileToBase64(file))).then(images => {
|
||||||
|
this.publishImages = this.publishImages.concat(images);
|
||||||
|
});
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearImages() {
|
||||||
|
this.publishImages = [];
|
||||||
|
},
|
||||||
|
async submitPublish() {
|
||||||
|
if (!this.publishForm.content.trim() && this.publishImages.length === 0) {
|
||||||
|
this.$message.warning('请填写文案或选择图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.publishing = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/friend_circle/publish', {
|
||||||
|
content: this.publishForm.content,
|
||||||
|
blacklist: this.publishForm.blacklist,
|
||||||
|
with_user_list: this.publishForm.with_user_list,
|
||||||
|
images: this.publishImages
|
||||||
|
});
|
||||||
|
if (response.data.success) {
|
||||||
|
this.$message.success('朋友圈发布成功');
|
||||||
|
this.publishForm = { content: '', blacklist: '', with_user_list: '' };
|
||||||
|
this.publishImages = [];
|
||||||
|
this.loadPosts();
|
||||||
|
} else {
|
||||||
|
this.$message.error(response.data.message || '发布失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error.response?.data?.message || '发布朋友圈失败');
|
||||||
|
} finally {
|
||||||
|
this.publishing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openCommentDialog(post) {
|
||||||
|
this.selectedPost = post;
|
||||||
|
this.commentForm = { content: '', reply_comment_id: 0 };
|
||||||
|
this.commentDialogVisible = true;
|
||||||
|
},
|
||||||
|
async submitComment() {
|
||||||
|
if (!this.selectedPost || !this.commentForm.content.trim()) {
|
||||||
|
this.$message.warning('请输入评论内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.commentSubmitting = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/friend_circle/comment', {
|
||||||
|
id: this.selectedPost.id,
|
||||||
|
content: this.commentForm.content,
|
||||||
|
reply_comment_id: this.commentForm.reply_comment_id
|
||||||
|
});
|
||||||
|
if (response.data.success) {
|
||||||
|
this.$message.success('评论成功');
|
||||||
|
this.commentDialogVisible = false;
|
||||||
|
this.loadPosts();
|
||||||
|
} else {
|
||||||
|
this.$message.error(response.data.message || '评论失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error.response?.data?.message || '评论失败');
|
||||||
|
} finally {
|
||||||
|
this.commentSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async toggleLike(post, cancel) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/friend_circle/like', {
|
||||||
|
id: post.id,
|
||||||
|
cancel: cancel
|
||||||
|
});
|
||||||
|
if (response.data.success) {
|
||||||
|
this.$message.success(cancel ? '已取消点赞' : '点赞成功');
|
||||||
|
this.loadPosts();
|
||||||
|
} else {
|
||||||
|
this.$message.error(response.data.message || '操作失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error.response?.data?.message || '点赞操作失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openRawDialog(post) {
|
||||||
|
this.rawDialogContent = JSON.stringify(post.raw || post, null, 2);
|
||||||
|
this.rawDialogVisible = true;
|
||||||
|
},
|
||||||
|
previewImage(media) {
|
||||||
|
this.previewImageUrl = media.url || media.thumb || '';
|
||||||
|
this.imagePreviewVisible = true;
|
||||||
|
},
|
||||||
|
resetFilters() {
|
||||||
|
this.viewMode = 'home';
|
||||||
|
this.selectedTowxid = '';
|
||||||
|
this.manualTowxid = '';
|
||||||
|
this.maxId = 0;
|
||||||
|
this.loadPosts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-shell { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.page-hero {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 24px 26px; border-radius: 24px;
|
||||||
|
background: linear-gradient(135deg, rgba(34,197,94,0.10), rgba(56,189,248,0.08), rgba(255,255,255,0.92));
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #16a34a; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; }
|
||||||
|
.page-hero-copy p { color: #64748b; font-size: 14px; }
|
||||||
|
.page-hero-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.friend-circle-grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr); gap: 16px; align-items: start; }
|
||||||
|
.friend-circle-main, .friend-circle-side { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
|
||||||
|
.workspace-header p { font-size: 13px; color: #64748b; }
|
||||||
|
.filter-form { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; }
|
||||||
|
.post-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.post-card { overflow: hidden; }
|
||||||
|
.post-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.post-author { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.post-author-name { font-size: 16px; font-weight: 700; color: #0f172a; }
|
||||||
|
.post-meta { font-size: 12px; color: #64748b; margin-top: 3px; }
|
||||||
|
.post-actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.post-content { color: #0f172a; line-height: 1.8; margin-bottom: 14px; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.post-media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
||||||
|
.post-media-item img, .upload-preview-item img {
|
||||||
|
width: 100%; height: 108px; object-fit: cover; border-radius: 16px; cursor: pointer;
|
||||||
|
border: 1px solid rgba(148,163,184,0.14); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
.post-feedback { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.feedback-block {
|
||||||
|
padding: 14px; border-radius: 18px; background: rgba(248,250,252,0.88);
|
||||||
|
border: 1px solid rgba(148,163,184,0.12);
|
||||||
|
}
|
||||||
|
.feedback-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 8px; }
|
||||||
|
.feedback-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.feedback-chip {
|
||||||
|
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 999px; font-size: 12px;
|
||||||
|
color: #334155; background: rgba(255,255,255,0.95); border: 1px solid rgba(148,163,184,0.12);
|
||||||
|
}
|
||||||
|
.feedback-empty { color: #94a3b8; font-size: 12px; }
|
||||||
|
.comment-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.comment-item { font-size: 13px; color: #334155; line-height: 1.6; }
|
||||||
|
.comment-author { font-weight: 700; color: #0f172a; margin-right: 8px; }
|
||||||
|
.danger-text { color: #ef4444 !important; }
|
||||||
|
.upload-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.upload-trigger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 0 14px;
|
||||||
|
border-radius: 12px; background: rgba(255,255,255,0.92); border: 1px solid rgba(148,163,184,0.2);
|
||||||
|
color: #0f172a; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.upload-trigger input { display: none; }
|
||||||
|
.upload-preview-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 12px; }
|
||||||
|
.raw-json {
|
||||||
|
margin: 0; max-height: 520px; overflow: auto; white-space: pre-wrap; word-break: break-all;
|
||||||
|
padding: 14px; border-radius: 16px; background: rgba(248,250,252,0.9); color: #334155;
|
||||||
|
border: 1px solid rgba(148,163,184,0.12);
|
||||||
|
}
|
||||||
|
.large-preview { width: 100%; border-radius: 18px; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.friend-circle-grid { grid-template-columns: 1fr; }
|
||||||
|
.post-feedback { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from wechat_ipad import UserLoggedOut
|
from wechat_ipad import UserLoggedOut
|
||||||
|
from wechat_ipad.client.friend_circle import FriendCircleMixin
|
||||||
from wechat_ipad.client.firends import FriendMixin
|
from wechat_ipad.client.firends import FriendMixin
|
||||||
from wechat_ipad.client.group import ChatroomMixin
|
from wechat_ipad.client.group import ChatroomMixin
|
||||||
from wechat_ipad.client.login import LoginMixin
|
from wechat_ipad.client.login import LoginMixin
|
||||||
@@ -7,7 +8,7 @@ from wechat_ipad.client.tools import ToolMixin
|
|||||||
from wechat_ipad.client.user import UserMixin
|
from wechat_ipad.client.user import UserMixin
|
||||||
|
|
||||||
|
|
||||||
class WechatAPIClient(LoginMixin, MessageMixin, FriendMixin, ChatroomMixin, UserMixin,
|
class WechatAPIClient(LoginMixin, MessageMixin, FriendCircleMixin, FriendMixin, ChatroomMixin, UserMixin,
|
||||||
ToolMixin):
|
ToolMixin):
|
||||||
|
|
||||||
# 这里都是需要结合多个功能的方法
|
# 这里都是需要结合多个功能的方法
|
||||||
|
|||||||
145
wechat_ipad/client/friend_circle.py
Normal file
145
wechat_ipad/client/friend_circle.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from wechat_ipad import UserLoggedOut
|
||||||
|
from wechat_ipad.client.base import WechatAPIClientBase
|
||||||
|
from wechat_ipad.models.friend_circle_info import build_friend_circle_xml
|
||||||
|
|
||||||
|
|
||||||
|
class FriendCircleMixin(WechatAPIClientBase):
|
||||||
|
async def get_friend_circle_list(self, max_id: int = 0, first_page_md5: str = "") -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {"Wxid": self.wxid, "Maxid": max_id, "Fristpagemd5": first_page_md5}
|
||||||
|
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/GetList", json=json_param)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
|
|
||||||
|
async def get_friend_circle_detail(self, towxid: str, max_id: int = 0, first_page_md5: str = "") -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {
|
||||||
|
"Wxid": self.wxid,
|
||||||
|
"Towxid": towxid,
|
||||||
|
"Maxid": max_id,
|
||||||
|
"Fristpagemd5": first_page_md5
|
||||||
|
}
|
||||||
|
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/GetDetail", json=json_param)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
|
|
||||||
|
async def get_friend_circle_id_detail(self, object_id: Union[str, int], towxid: str = "") -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {"Wxid": self.wxid, "Towxid": towxid, "Id": int(object_id)}
|
||||||
|
response = await session.post(
|
||||||
|
f"http://{self.ip}:{self.port}/api/FriendCircle/GetIdDetail",
|
||||||
|
json=json_param
|
||||||
|
)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
|
|
||||||
|
async def publish_friend_circle(self, content: str, media_items: list[dict] | None = None,
|
||||||
|
blacklist: str = "", with_user_list: str = "") -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
xml_content = build_friend_circle_xml(self.wxid, content, media_items=media_items)
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {
|
||||||
|
"Wxid": self.wxid,
|
||||||
|
"Content": xml_content,
|
||||||
|
"BlackList": blacklist,
|
||||||
|
"WithUserList": with_user_list
|
||||||
|
}
|
||||||
|
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Messages", json=json_param)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
|
|
||||||
|
async def friend_circle_comment(self, object_id: str, content: str = "", type: int = 2,
|
||||||
|
reply_comment_id: int = 0) -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {
|
||||||
|
"Wxid": self.wxid,
|
||||||
|
"Id": str(object_id),
|
||||||
|
"Type": int(type),
|
||||||
|
"Content": content,
|
||||||
|
"ReplyCommnetId": int(reply_comment_id)
|
||||||
|
}
|
||||||
|
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Comment", json=json_param)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
|
|
||||||
|
async def friend_circle_operation(self, object_id: str, type: int, comment_id: int = 0) -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {
|
||||||
|
"Wxid": self.wxid,
|
||||||
|
"Id": str(object_id),
|
||||||
|
"Type": int(type),
|
||||||
|
"CommnetId": int(comment_id)
|
||||||
|
}
|
||||||
|
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Operation", json=json_param)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
|
|
||||||
|
async def sync_friend_circle(self, sync_key: str = "") -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {"Wxid": self.wxid, "Synckey": sync_key}
|
||||||
|
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/MmSnsSync", json=json_param)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
|
|
||||||
|
async def upload_friend_circle_media(self, media: Union[str, bytes, os.PathLike]) -> dict:
|
||||||
|
if not self.wxid:
|
||||||
|
raise UserLoggedOut("请先登录")
|
||||||
|
|
||||||
|
if isinstance(media, str):
|
||||||
|
media_base64 = media.split(",", 1)[1] if "," in media else media
|
||||||
|
elif isinstance(media, bytes):
|
||||||
|
media_base64 = base64.b64encode(media).decode()
|
||||||
|
elif isinstance(media, os.PathLike):
|
||||||
|
async with aiofiles.open(media, "rb") as f:
|
||||||
|
media_base64 = base64.b64encode(await f.read()).decode()
|
||||||
|
else:
|
||||||
|
raise ValueError("media should be str, bytes, or path")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
json_param = {"Wxid": self.wxid, "Base64": media_base64}
|
||||||
|
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Upload", json=json_param)
|
||||||
|
json_resp = await response.json()
|
||||||
|
if json_resp.get("Success"):
|
||||||
|
return json_resp.get("Data", {})
|
||||||
|
self.error_handler(json_resp)
|
||||||
@@ -1,25 +1,65 @@
|
|||||||
FRIEND_CIRCLE_INFO = """
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value) -> float:
|
||||||
|
try:
|
||||||
|
return float(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def generate_timeline_id() -> str:
|
||||||
|
return f"{int(time.time() * 1000)}{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_media_xml(media_items: Iterable[dict]) -> str:
|
||||||
|
parts = []
|
||||||
|
for index, item in enumerate(media_items):
|
||||||
|
url = item.get("url", "")
|
||||||
|
thumb = item.get("thumb", url)
|
||||||
|
media_id = item.get("id") or f"{int(time.time() * 1000)}{index}"
|
||||||
|
md5 = item.get("md5", "")
|
||||||
|
total_size = item.get("total_size", 0)
|
||||||
|
width = item.get("width", 0)
|
||||||
|
height = item.get("height", 0)
|
||||||
|
parts.append(
|
||||||
|
f"""
|
||||||
|
<media>
|
||||||
|
<id><![CDATA[{media_id}]]></id>
|
||||||
|
<type><![CDATA[2]]></type>
|
||||||
|
<title></title>
|
||||||
|
<description></description>
|
||||||
|
<private><![CDATA[0]]></private>
|
||||||
|
<url type="1" md5="{md5}"><![CDATA[{url}]]></url>
|
||||||
|
<thumb type="1"><![CDATA[{thumb}]]></thumb>
|
||||||
|
<videoDuration><![CDATA[0.0]]></videoDuration>
|
||||||
|
<size totalSize="{_safe_float(total_size):.1f}" width="{_safe_float(width):.1f}" height="{_safe_float(height):.1f}"></size>
|
||||||
|
</media>
|
||||||
|
""".strip()
|
||||||
|
)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_friend_circle_xml(wxid: str, content: str, media_items: list[dict] | None = None,
|
||||||
|
timeline_id: str | None = None, create_time: int | None = None) -> str:
|
||||||
|
media_items = media_items or []
|
||||||
|
timeline_id = timeline_id or generate_timeline_id()
|
||||||
|
create_time = create_time or int(time.time())
|
||||||
|
content_style = 1 if media_items else 2
|
||||||
|
media_xml = build_media_xml(media_items)
|
||||||
|
|
||||||
|
return f"""
|
||||||
<TimelineObject>
|
<TimelineObject>
|
||||||
<id>
|
<id><![CDATA[{timeline_id}]]></id>
|
||||||
<![CDATA[{id}]]>
|
<username><![CDATA[{wxid}]]></username>
|
||||||
</id>
|
<createTime><![CDATA[{create_time}]]></createTime>
|
||||||
<username>
|
|
||||||
<![CDATA[{wxid}]]>
|
|
||||||
</username>
|
|
||||||
<createTime>
|
|
||||||
<![CDATA[{time}]]>
|
|
||||||
</createTime>
|
|
||||||
<contentDescShowType>0</contentDescShowType>
|
<contentDescShowType>0</contentDescShowType>
|
||||||
<contentDescScene>0</contentDescScene>
|
<contentDescScene>0</contentDescScene>
|
||||||
<private>
|
<private><![CDATA[0]]></private>
|
||||||
<![CDATA[0]]>
|
<contentDesc><![CDATA[{content}]]></contentDesc>
|
||||||
</private>
|
<contentattr><![CDATA[0]]></contentattr>
|
||||||
<contentDesc>
|
|
||||||
<![CDATA[{content}]]>
|
|
||||||
</contentDesc>
|
|
||||||
<contentattr>
|
|
||||||
<![CDATA[0]]>
|
|
||||||
</contentattr>
|
|
||||||
<sourceUserName></sourceUserName>
|
<sourceUserName></sourceUserName>
|
||||||
<publicUserName></publicUserName>
|
<publicUserName></publicUserName>
|
||||||
<sourceNickName></sourceNickName>
|
<sourceNickName></sourceNickName>
|
||||||
@@ -27,58 +67,24 @@ FRIEND_CIRCLE_INFO = """
|
|||||||
<weappInfo>
|
<weappInfo>
|
||||||
<appUserName></appUserName>
|
<appUserName></appUserName>
|
||||||
<pagePath></pagePath>
|
<pagePath></pagePath>
|
||||||
<version>
|
<version><![CDATA[0]]></version>
|
||||||
<![CDATA[0]]>
|
|
||||||
</version>
|
|
||||||
<isHidden>0</isHidden>
|
<isHidden>0</isHidden>
|
||||||
<debugMode>
|
<debugMode><![CDATA[0]]></debugMode>
|
||||||
<![CDATA[0]]>
|
|
||||||
</debugMode>
|
|
||||||
<shareActionId></shareActionId>
|
<shareActionId></shareActionId>
|
||||||
<isGame>
|
<isGame><![CDATA[0]]></isGame>
|
||||||
<![CDATA[0]]>
|
|
||||||
</isGame>
|
|
||||||
<messageExtraData></messageExtraData>
|
<messageExtraData></messageExtraData>
|
||||||
<subType>
|
<subType><![CDATA[0]]></subType>
|
||||||
<![CDATA[0]]>
|
|
||||||
</subType>
|
|
||||||
<preloadResources></preloadResources>
|
<preloadResources></preloadResources>
|
||||||
</weappInfo>
|
</weappInfo>
|
||||||
<canvasInfoXml></canvasInfoXml>
|
<canvasInfoXml></canvasInfoXml>
|
||||||
<ContentObject>
|
<ContentObject>
|
||||||
<contentStyle>
|
<contentStyle><![CDATA[{content_style}]]></contentStyle>
|
||||||
<![CDATA[1]]>
|
<contentSubStyle><![CDATA[0]]></contentSubStyle>
|
||||||
</contentStyle>
|
|
||||||
<contentSubStyle>
|
|
||||||
<![CDATA[0]]>
|
|
||||||
</contentSubStyle>
|
|
||||||
<title></title>
|
<title></title>
|
||||||
<description></description>
|
<description></description>
|
||||||
<contentUrl></contentUrl>
|
<contentUrl></contentUrl>
|
||||||
<mediaList>
|
<mediaList>
|
||||||
<media>
|
{media_xml}
|
||||||
<id>
|
|
||||||
<![CDATA[14672447414385119864]]>
|
|
||||||
</id>
|
|
||||||
<type>
|
|
||||||
<![CDATA[2]]>
|
|
||||||
</type>
|
|
||||||
<title></title>
|
|
||||||
<description></description>
|
|
||||||
<private>
|
|
||||||
<![CDATA[0]]>
|
|
||||||
</private>
|
|
||||||
<url type=\"1\" md5=\"c661215f338618b3282a6ea5174a0fb5\">
|
|
||||||
<![CDATA[http://szmmsns.qpic.cn/mmsns/AcIhsXSWkDeNF6K5icia87OmccBSE2sDH5gib5UWibjTCU1snn0WANibQoy4Obad1ibmQO6aNQXOTP4Fc/0]]>
|
|
||||||
</url>
|
|
||||||
<thumb type=\"1\">
|
|
||||||
<![CDATA[http://szmmsns.qpic.cn/mmsns/AcIhsXSWkDeNF6K5icia87OmccBSE2sDH5gib5UWibjTCU1snn0WANibQoy4Obad1ibmQO6aNQXOTP4Fc/150]]>
|
|
||||||
</thumb>
|
|
||||||
<videoDuration>
|
|
||||||
<![CDATA[0.0]]>
|
|
||||||
</videoDuration>
|
|
||||||
<size totalSize=\"47880.0\" width=\"1080.0\" height=\"1080.0\"></size>
|
|
||||||
</media>
|
|
||||||
</mediaList>
|
</mediaList>
|
||||||
</ContentObject>
|
</ContentObject>
|
||||||
<actionInfo>
|
<actionInfo>
|
||||||
@@ -91,11 +97,11 @@ FRIEND_CIRCLE_INFO = """
|
|||||||
<appInfo>
|
<appInfo>
|
||||||
<id></id>
|
<id></id>
|
||||||
</appInfo>
|
</appInfo>
|
||||||
<location poiClassifyId=\"\" poiName=\"\" poiAddress=\"\" poiClassifyType=\"0\" city=\"\"></location>
|
<location poiClassifyId="" poiName="" poiAddress="" poiClassifyType="0" city=""></location>
|
||||||
<streamvideo>
|
<streamvideo>
|
||||||
<streamvideourl></streamvideourl>
|
<streamvideourl></streamvideourl>
|
||||||
<streamvideothumburl></streamvideothumburl>
|
<streamvideothumburl></streamvideothumburl>
|
||||||
<streamvideoweburl></streamvideoweburl>
|
<streamvideoweburl></streamvideoweburl>
|
||||||
</streamvideo>
|
</streamvideo>
|
||||||
</TimelineObject>
|
</TimelineObject>
|
||||||
"""
|
""".strip()
|
||||||
|
|||||||
Reference in New Issue
Block a user