ForcePilot/backend/package/yuxi/channels/adapters/urbit/format.py
Kris 80e3f66974 refactor(urbit): 整理导入顺序并修复多处代码细节
1.  统一调整多个文件的导入排序,将TYPE_CHECKING相关导入放在正确位置
2.  修复rate_limiter中当限制数<=0时直接返回false的逻辑
3.  为message_cache新增更新消息内容的方法
4.  重构extract_graph_content支持日记类型内容提取
5.  调整@提及匹配的正则表达式,避免误匹配
6.  完善invite_manager,添加客户端和凭据支持并实现自动接受群邀请逻辑
7.  调整adapter.py中的导入顺序和初始化逻辑
8.  修复monitor中的编辑事件处理,改为异步处理并实现消息更新缓存
9.  调整datetime导入顺序,统一使用UTC在前的格式
2026-05-13 16:16:27 +08:00

266 lines
8.6 KiB
Python

from __future__ import annotations
import re
from typing import Any
_SHIP_PATTERN = re.compile(r"^~[a-z]{3,}(-[a-z]{3,})*$", re.IGNORECASE)
def is_valid_ship(ship: str) -> bool:
return bool(_SHIP_PATTERN.match(ship))
def strip_ship_tilde(ship: str) -> str:
return ship.lstrip("~")
def ensure_ship_tilde(ship: str) -> str:
return ship if ship.startswith("~") else f"~{ship}"
def build_dm_channel_id(target_ship: str, sender_ship: str) -> str:
t = strip_ship_tilde(target_ship)
s = strip_ship_tilde(sender_ship)
return f"~{t}/dm--{s}"
def build_group_channel_id(host_ship: str, group_name: str, channel_type: str = "chat") -> str:
h = strip_ship_tilde(host_ship)
return f"~{h}/{group_name}/{channel_type}"
def build_channel_path(chat_type: str, **kwargs) -> str:
if chat_type == "direct":
target = strip_ship_tilde(kwargs["target_ship"])
return f"/dm/~{target}"
group_name = kwargs["group_name"]
ch_type = kwargs.get("channel_type", "chat")
return f"/{ch_type}/{group_name}"
def parse_graph_update(raw: dict[str, Any]) -> dict[str, Any]:
graph_update = raw.get("graph-update", {})
if not isinstance(graph_update, dict) or not graph_update:
return {}
result: dict[str, Any] = {}
resource = graph_update.get("resource", {})
result["resource_path"] = resource.get("path", "")
result["resource_type"] = resource.get("type", "chat")
result["ship"] = graph_update.get("ship", "")
result["index"] = graph_update.get("index", "")
result["time"] = graph_update.get("time")
additions = graph_update.get("additions", {})
if additions:
result["content"] = _extract_graph_content(additions, resource_type=result.get("resource_type", "chat"))
if "reaction" in graph_update:
result["reaction"] = graph_update["reaction"]
return result
def _extract_graph_content(additions: dict[str, Any], resource_type: str = "chat") -> str:
parts: list[str] = []
for node_data in additions.values():
post = node_data.get("post", {})
contents = post.get("contents", [])
if resource_type == "diary":
title = post.get("title", "")
if title:
parts.append(f"\U0001f4dd {title}\n")
for item in contents:
if "text" in item:
parts.append(item["text"])
elif "mention" in item:
parts.append(f"~{item['mention']}")
elif "url" in item:
parts.append(item["url"])
elif "code" in item:
code_block = item["code"]
lang = code_block.get("lang", "")
code_text = code_block.get("code", "") if isinstance(code_block, dict) else str(code_block)
if lang:
parts.append(f"```{lang}\n{code_text}\n```\n")
else:
parts.append(f"```\n{code_text}\n```\n")
elif "blockquote" in item:
quoted = item["blockquote"]
if isinstance(quoted, list):
quoted_text = _extract_content_list(quoted)
parts.append(f"> {quoted_text}\n")
else:
parts.append(f"> {quoted}\n")
elif "image" in item:
img = item["image"]
img_url = img.get("src", "") if isinstance(img, dict) else str(img)
if img_url:
parts.append(f"[Image: {img_url}]")
elif "ship" in item:
parts.append(f"~{item['ship']}")
elif isinstance(item, str):
parts.append(item)
return "".join(parts)
def _extract_content_list(contents: list[Any]) -> str:
parts: list[str] = []
for item in contents:
if isinstance(item, dict):
if "text" in item:
parts.append(item["text"])
elif "mention" in item:
parts.append(f"~{item['mention']}")
elif "url" in item:
parts.append(item["url"])
elif "code" in item:
code_block = item["code"]
code_text = code_block.get("code", "") if isinstance(code_block, dict) else str(code_block)
parts.append(code_text)
elif "ship" in item:
parts.append(f"~{item['ship']}")
elif isinstance(item, str):
parts.append(item)
return "".join(parts)
def extract_graph_mentions(additions: dict[str, Any]) -> list[str]:
mentions: list[str] = []
for node_data in additions.values():
post = node_data.get("post", {})
contents = post.get("contents", [])
for item in contents:
if "mention" in item:
mentions.append(item["mention"])
return mentions
def extract_graph_attachments(additions: dict[str, Any]) -> list[dict[str, Any]]:
from yuxi.channels.models import Attachment
attachments: list[dict[str, Any]] = []
for node_data in additions.values():
post = node_data.get("post", {})
contents = post.get("contents", [])
for item in contents:
if "image" in item:
img = item["image"]
src = img.get("src", "") if isinstance(img, dict) else str(img)
if src:
attachment = Attachment(
type="image",
url=src,
mime_type="image/*",
)
attachments.append(attachment.model_dump())
elif "file" in item:
file_info = item["file"]
if isinstance(file_info, dict):
attachment = Attachment(
type="file",
url=file_info.get("src", ""),
filename=file_info.get("name"),
size_bytes=file_info.get("size"),
)
attachments.append(attachment.model_dump())
return attachments
def build_poke_payload(
host_ship: str,
content: str,
channel_path: str,
*,
continuation: bool = False,
reply_to: str | None = None,
media_content: list[dict[str, Any]] | None = None,
chat_type: str = "group",
) -> dict[str, Any]:
_MAX_CONTENT_LENGTH = 100_000
if len(content) > _MAX_CONTENT_LENGTH:
raise ValueError(f"Content length {len(content)} exceeds maximum {_MAX_CONTENT_LENGTH}")
mark = "chat-dm-action" if chat_type == "direct" else "chat-message"
if media_content:
content_list = media_content
else:
from .story import markdown_to_story
story = markdown_to_story(content)
content_list = story
payload: dict[str, Any] = {
"action": "poke",
"ship": host_ship,
"app": "chat",
"mark": mark,
"json": {
"path": channel_path,
"envelope": {
"content": content_list,
"author": f"~{host_ship}",
},
},
}
if continuation:
payload["json"]["envelope"]["continuation"] = True
if reply_to:
payload["json"]["envelope"]["reply_to"] = reply_to
return payload
def build_chat_action_payload(
host_ship: str,
channel_path: str,
action: str,
msg_id: str,
*,
content: str | None = None,
emoji: str | None = None,
) -> dict[str, Any]:
action_data: dict[str, Any] = {}
match action:
case "add-react":
action_data = {"add-react": {"react": emoji, "msg": msg_id}}
case "del-react":
action_data = {"del-react": {"react": emoji, "msg": msg_id}}
case "edit":
action_data = {"edit": {"msg": msg_id, "content": [{"text": content}]}}
case "del":
action_data = {"del": {"msg": msg_id}}
case "typing":
action_data = {"typing": None}
case _:
raise ValueError(f"Unknown chat action: {action}")
return {
"action": "poke",
"ship": host_ship,
"app": "chat",
"mark": "chat-action",
"json": {
"path": channel_path,
"action": action_data,
},
}
def build_media_content(media_type: str, data: str) -> list[dict[str, Any]]:
match media_type:
case "image":
return [{"image": {"src": data, "width": 0, "height": 0}}]
case "video":
return [{"video": {"src": data}}]
case "audio":
return [{"audio": {"src": data}}]
case "url":
return [{"url": data}]
case "reference":
return [{"reference": {"text": data}}]
case _:
return [{"text": data}]