Files
abot/admin/dashboard/blueprints/friend_circle.py
2026-04-07 12:50:50 +08:00

391 lines
13 KiB
Python

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