feat: adding support for media and quoted message attachments for feishu (#5018)
This commit is contained in:
@@ -867,6 +867,8 @@ async def build_main_agent(
|
||||
return None
|
||||
|
||||
req.prompt = event.message_str[len(config.provider_wake_prefix) :]
|
||||
|
||||
# media files attachments
|
||||
for comp in event.message_obj.message:
|
||||
if isinstance(comp, Image):
|
||||
image_path = await comp.convert_to_file_path()
|
||||
@@ -882,6 +884,35 @@ async def build_main_agent(
|
||||
text=f"[File Attachment: name {file_name}, path {file_path}]"
|
||||
)
|
||||
)
|
||||
# quoted message attachments
|
||||
reply_comps = [
|
||||
comp
|
||||
for comp in event.message_obj.message
|
||||
if isinstance(comp, Reply) and comp.chain
|
||||
]
|
||||
for comp in reply_comps:
|
||||
if not comp.chain:
|
||||
continue
|
||||
for reply_comp in comp.chain:
|
||||
if isinstance(reply_comp, Image):
|
||||
image_path = await reply_comp.convert_to_file_path()
|
||||
req.image_urls.append(image_path)
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(
|
||||
text=f"[Image Attachment in quoted message: path {image_path}]"
|
||||
)
|
||||
)
|
||||
elif isinstance(reply_comp, File):
|
||||
file_path = await reply_comp.get_file()
|
||||
file_name = reply_comp.name or os.path.basename(file_path)
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(
|
||||
text=(
|
||||
f"[File Attachment in quoted message: "
|
||||
f"name {file_name}, path {file_path}]"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
conversation = await _get_session_conv(event, plugin_context)
|
||||
req.conversation = conversation
|
||||
|
||||
@@ -3,10 +3,13 @@ import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import (
|
||||
GetMessageRequest,
|
||||
GetMessageResourceRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor
|
||||
@@ -22,6 +25,7 @@ from astrbot.api.platform import (
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
@@ -91,6 +95,347 @@ class LarkPlatformAdapter(Platform):
|
||||
|
||||
self.event_id_timestamps: dict[str, float] = {}
|
||||
|
||||
async def _download_message_resource(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
file_key: str,
|
||||
resource_type: str,
|
||||
) -> bytes | None:
|
||||
if self.lark_api.im is None:
|
||||
logger.error("[Lark] API Client im 模块未初始化")
|
||||
return None
|
||||
|
||||
request = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message_id)
|
||||
.file_key(file_key)
|
||||
.type(resource_type)
|
||||
.build()
|
||||
)
|
||||
response = await self.lark_api.im.v1.message_resource.aget(request)
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Lark] 下载消息资源失败 type={resource_type}, key={file_key}, "
|
||||
f"code={response.code}, msg={response.msg}",
|
||||
)
|
||||
return None
|
||||
|
||||
if response.file is None:
|
||||
logger.error(f"[Lark] 消息资源响应中不包含文件流: {file_key}")
|
||||
return None
|
||||
|
||||
return response.file.read()
|
||||
|
||||
@staticmethod
|
||||
def _build_message_str_from_components(
|
||||
components: list[Comp.BaseMessageComponent],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
for comp in components:
|
||||
if isinstance(comp, Comp.Plain):
|
||||
text = comp.text.strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
elif isinstance(comp, Comp.At):
|
||||
name = str(comp.name or comp.qq or "").strip()
|
||||
if name:
|
||||
parts.append(f"@{name}")
|
||||
elif isinstance(comp, Comp.Image):
|
||||
parts.append("[image]")
|
||||
elif isinstance(comp, Comp.File):
|
||||
parts.append(str(comp.name or "[file]"))
|
||||
elif isinstance(comp, Comp.Record):
|
||||
parts.append("[audio]")
|
||||
elif isinstance(comp, Comp.Video):
|
||||
parts.append("[video]")
|
||||
|
||||
return " ".join(parts).strip()
|
||||
|
||||
@staticmethod
|
||||
def _parse_post_content(content: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in content.get("content", []):
|
||||
if isinstance(item, list):
|
||||
for comp in item:
|
||||
if isinstance(comp, dict):
|
||||
result.append(comp)
|
||||
elif isinstance(item, dict):
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _build_at_map(mentions: list[Any] | None) -> dict[str, Comp.At]:
|
||||
at_map: dict[str, Comp.At] = {}
|
||||
if not mentions:
|
||||
return at_map
|
||||
|
||||
for mention in mentions:
|
||||
key = getattr(mention, "key", None)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
mention_id = getattr(mention, "id", None)
|
||||
open_id = ""
|
||||
if mention_id is not None:
|
||||
if hasattr(mention_id, "open_id"):
|
||||
open_id = getattr(mention_id, "open_id", "") or ""
|
||||
else:
|
||||
open_id = str(mention_id)
|
||||
|
||||
mention_name = str(getattr(mention, "name", "") or "")
|
||||
at_map[key] = Comp.At(qq=open_id, name=mention_name)
|
||||
|
||||
return at_map
|
||||
|
||||
async def _parse_message_components(
|
||||
self,
|
||||
*,
|
||||
message_id: str | None,
|
||||
message_type: str,
|
||||
content: dict[str, Any],
|
||||
at_map: dict[str, Comp.At],
|
||||
) -> list[Comp.BaseMessageComponent]:
|
||||
components: list[Comp.BaseMessageComponent] = []
|
||||
|
||||
if message_type == "text":
|
||||
message_str_raw = str(content.get("text", ""))
|
||||
at_pattern = r"(@_user_\d+)"
|
||||
parts = re.split(at_pattern, message_str_raw)
|
||||
for part in parts:
|
||||
segment = part.strip()
|
||||
if not segment:
|
||||
continue
|
||||
if segment in at_map:
|
||||
components.append(at_map[segment])
|
||||
else:
|
||||
components.append(Comp.Plain(segment))
|
||||
return components
|
||||
|
||||
if message_type in ("post", "image"):
|
||||
if message_type == "image":
|
||||
comp_list = [
|
||||
{
|
||||
"tag": "img",
|
||||
"image_key": content.get("image_key"),
|
||||
},
|
||||
]
|
||||
else:
|
||||
comp_list = self._parse_post_content(content)
|
||||
|
||||
for comp in comp_list:
|
||||
tag = comp.get("tag")
|
||||
if tag == "at":
|
||||
user_key = str(comp.get("user_id", ""))
|
||||
if user_key in at_map:
|
||||
components.append(at_map[user_key])
|
||||
elif tag == "text":
|
||||
text = str(comp.get("text", "")).strip()
|
||||
if text:
|
||||
components.append(Comp.Plain(text))
|
||||
elif tag == "a":
|
||||
text = str(comp.get("text", "")).strip()
|
||||
href = str(comp.get("href", "")).strip()
|
||||
if text and href:
|
||||
components.append(Comp.Plain(f"{text}({href})"))
|
||||
elif text:
|
||||
components.append(Comp.Plain(text))
|
||||
elif tag == "img":
|
||||
image_key = str(comp.get("image_key", "")).strip()
|
||||
if not image_key:
|
||||
continue
|
||||
if not message_id:
|
||||
logger.error("[Lark] 图片消息缺少 message_id")
|
||||
continue
|
||||
image_bytes = await self._download_message_resource(
|
||||
message_id=message_id,
|
||||
file_key=image_key,
|
||||
resource_type="image",
|
||||
)
|
||||
if image_bytes is None:
|
||||
continue
|
||||
image_base64 = base64.b64encode(image_bytes).decode()
|
||||
components.append(Comp.Image.fromBase64(image_base64))
|
||||
elif tag == "media":
|
||||
file_key = str(comp.get("file_key", "")).strip()
|
||||
file_name = (
|
||||
str(comp.get("file_name", "")).strip() or "lark_media.mp4"
|
||||
)
|
||||
if not file_key:
|
||||
continue
|
||||
if not message_id:
|
||||
logger.error("[Lark] 富文本视频消息缺少 message_id")
|
||||
continue
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="post_media",
|
||||
file_name=file_name,
|
||||
default_suffix=".mp4",
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.Video(file=file_path, path=file_path))
|
||||
|
||||
return components
|
||||
|
||||
if message_type == "file":
|
||||
file_key = str(content.get("file_key", "")).strip()
|
||||
file_name = str(content.get("file_name", "")).strip() or "lark_file"
|
||||
if not message_id:
|
||||
logger.error("[Lark] 文件消息缺少 message_id")
|
||||
return components
|
||||
if not file_key:
|
||||
logger.error("[Lark] 文件消息缺少 file_key")
|
||||
return components
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="file",
|
||||
file_name=file_name,
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.File(name=file_name, file=file_path))
|
||||
return components
|
||||
|
||||
if message_type == "audio":
|
||||
file_key = str(content.get("file_key", "")).strip()
|
||||
if not message_id:
|
||||
logger.error("[Lark] 音频消息缺少 message_id")
|
||||
return components
|
||||
if not file_key:
|
||||
logger.error("[Lark] 音频消息缺少 file_key")
|
||||
return components
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="audio",
|
||||
default_suffix=".opus",
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.Record(file=file_path, url=file_path))
|
||||
return components
|
||||
|
||||
if message_type == "media":
|
||||
file_key = str(content.get("file_key", "")).strip()
|
||||
file_name = str(content.get("file_name", "")).strip() or "lark_media.mp4"
|
||||
if not message_id:
|
||||
logger.error("[Lark] 视频消息缺少 message_id")
|
||||
return components
|
||||
if not file_key:
|
||||
logger.error("[Lark] 视频消息缺少 file_key")
|
||||
return components
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="media",
|
||||
file_name=file_name,
|
||||
default_suffix=".mp4",
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.Video(file=file_path, path=file_path))
|
||||
return components
|
||||
|
||||
return components
|
||||
|
||||
async def _build_reply_from_parent_id(
|
||||
self,
|
||||
parent_message_id: str,
|
||||
) -> Comp.Reply | None:
|
||||
if self.lark_api.im is None:
|
||||
logger.error("[Lark] API Client im 模块未初始化")
|
||||
return None
|
||||
|
||||
request = GetMessageRequest.builder().message_id(parent_message_id).build()
|
||||
response = await self.lark_api.im.v1.message.aget(request)
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Lark] 获取引用消息失败 id={parent_message_id}, "
|
||||
f"code={response.code}, msg={response.msg}",
|
||||
)
|
||||
return None
|
||||
|
||||
if response.data is None or not response.data.items:
|
||||
logger.error(
|
||||
f"[Lark] 引用消息响应为空 id={parent_message_id}",
|
||||
)
|
||||
return None
|
||||
|
||||
parent_message = response.data.items[0]
|
||||
quoted_message_id = parent_message.message_id or parent_message_id
|
||||
quoted_sender_id = (
|
||||
parent_message.sender.id
|
||||
if parent_message.sender and parent_message.sender.id
|
||||
else "unknown"
|
||||
)
|
||||
quoted_time_raw = parent_message.create_time or 0
|
||||
quoted_time = (
|
||||
quoted_time_raw // 1000
|
||||
if isinstance(quoted_time_raw, int) and quoted_time_raw > 10**11
|
||||
else quoted_time_raw
|
||||
)
|
||||
quoted_content = (
|
||||
parent_message.body.content if parent_message.body else ""
|
||||
) or ""
|
||||
quoted_type = parent_message.msg_type or ""
|
||||
quoted_content_json: dict[str, Any] = {}
|
||||
if quoted_content:
|
||||
try:
|
||||
parsed = json.loads(quoted_content)
|
||||
if isinstance(parsed, dict):
|
||||
quoted_content_json = parsed
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"[Lark] 解析引用消息内容失败 id={quoted_message_id}",
|
||||
)
|
||||
|
||||
quoted_at_map = self._build_at_map(parent_message.mentions)
|
||||
quoted_chain = await self._parse_message_components(
|
||||
message_id=quoted_message_id,
|
||||
message_type=quoted_type,
|
||||
content=quoted_content_json,
|
||||
at_map=quoted_at_map,
|
||||
)
|
||||
quoted_text = self._build_message_str_from_components(quoted_chain)
|
||||
sender_nickname = (
|
||||
quoted_sender_id[:8] if quoted_sender_id != "unknown" else "unknown"
|
||||
)
|
||||
|
||||
return Comp.Reply(
|
||||
id=quoted_message_id,
|
||||
chain=quoted_chain,
|
||||
sender_id=quoted_sender_id,
|
||||
sender_nickname=sender_nickname,
|
||||
time=quoted_time,
|
||||
message_str=quoted_text,
|
||||
text=quoted_text,
|
||||
)
|
||||
|
||||
async def _download_file_resource_to_temp(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
file_key: str,
|
||||
message_type: str,
|
||||
file_name: str = "",
|
||||
default_suffix: str = ".bin",
|
||||
) -> str | None:
|
||||
file_bytes = await self._download_message_resource(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
resource_type="file",
|
||||
)
|
||||
if file_bytes is None:
|
||||
return None
|
||||
|
||||
suffix = Path(file_name).suffix if file_name else default_suffix
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = (
|
||||
temp_dir / f"lark_{message_type}_{file_name}_{uuid4().hex[:4]}{suffix}"
|
||||
)
|
||||
temp_path.write_bytes(file_bytes)
|
||||
return str(temp_path.resolve())
|
||||
|
||||
def _clean_expired_events(self) -> None:
|
||||
"""清理超过 30 分钟的事件记录"""
|
||||
current_time = time.time()
|
||||
@@ -176,6 +521,11 @@ class LarkPlatformAdapter(Platform):
|
||||
abm.message_str = ""
|
||||
|
||||
at_list = {}
|
||||
if message.parent_id:
|
||||
reply_seg = await self._build_reply_from_parent_id(message.parent_id)
|
||||
if reply_seg:
|
||||
abm.message.append(reply_seg)
|
||||
|
||||
if message.mentions:
|
||||
for m in message.mentions:
|
||||
if m.id is None:
|
||||
@@ -198,80 +548,19 @@ class LarkPlatformAdapter(Platform):
|
||||
logger.error(f"[Lark] 解析消息内容失败: {message.content}")
|
||||
return
|
||||
|
||||
if message.message_type == "text":
|
||||
message_str_raw = content_json_b.get("text", "") # 带有 @ 的消息
|
||||
at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则
|
||||
# at_users = re.findall(at_pattern, message_str_raw)
|
||||
# 拆分文本,去掉AT符号部分
|
||||
parts = re.split(at_pattern, message_str_raw)
|
||||
for i in range(len(parts)):
|
||||
s = parts[i].strip()
|
||||
if not s:
|
||||
continue
|
||||
if s in at_list:
|
||||
abm.message.append(at_list[s])
|
||||
else:
|
||||
abm.message.append(Comp.Plain(parts[i].strip()))
|
||||
elif message.message_type == "post":
|
||||
_ls = []
|
||||
if not isinstance(content_json_b, dict):
|
||||
logger.error(f"[Lark] 消息内容不是 JSON Object: {message.content}")
|
||||
return
|
||||
|
||||
content_ls = content_json_b.get("content", [])
|
||||
for comp in content_ls:
|
||||
if isinstance(comp, list):
|
||||
_ls.extend(comp)
|
||||
elif isinstance(comp, dict):
|
||||
_ls.append(comp)
|
||||
content_json_b = _ls
|
||||
elif message.message_type == "image":
|
||||
content_json_b = [
|
||||
{
|
||||
"tag": "img",
|
||||
"image_key": content_json_b.get("image_key"),
|
||||
"style": [],
|
||||
},
|
||||
]
|
||||
|
||||
if message.message_type in ("post", "image"):
|
||||
for comp in content_json_b:
|
||||
if comp.get("tag") == "at":
|
||||
user_id = comp.get("user_id")
|
||||
if user_id in at_list:
|
||||
abm.message.append(at_list[user_id])
|
||||
elif comp.get("tag") == "text" and comp.get("text", "").strip():
|
||||
abm.message.append(Comp.Plain(comp["text"].strip()))
|
||||
elif comp.get("tag") == "img":
|
||||
image_key = comp.get("image_key")
|
||||
if not image_key:
|
||||
continue
|
||||
|
||||
request = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(cast(str, message.message_id))
|
||||
.file_key(image_key)
|
||||
.type("image")
|
||||
.build()
|
||||
)
|
||||
|
||||
if self.lark_api.im is None:
|
||||
logger.error("[Lark] API Client im 模块未初始化")
|
||||
continue
|
||||
|
||||
response = await self.lark_api.im.v1.message_resource.aget(request)
|
||||
if not response.success():
|
||||
logger.error(f"无法下载飞书图片: {image_key}")
|
||||
continue
|
||||
|
||||
if response.file is None:
|
||||
logger.error(f"飞书图片响应中不包含文件流: {image_key}")
|
||||
continue
|
||||
|
||||
image_bytes = response.file.read()
|
||||
image_base64 = base64.b64encode(image_bytes).decode()
|
||||
abm.message.append(Comp.Image.fromBase64(image_base64))
|
||||
|
||||
for comp in abm.message:
|
||||
if isinstance(comp, Comp.Plain):
|
||||
abm.message_str += comp.text
|
||||
logger.debug(f"[Lark] 解析消息内容: {content_json_b}")
|
||||
parsed_components = await self._parse_message_components(
|
||||
message_id=message.message_id,
|
||||
message_type=message.message_type or "unknown",
|
||||
content=content_json_b,
|
||||
at_map=at_list,
|
||||
)
|
||||
abm.message.extend(parsed_components)
|
||||
abm.message_str = self._build_message_str_from_components(parsed_components)
|
||||
|
||||
if message.message_id is None:
|
||||
logger.error("[Lark] 消息缺少 message_id")
|
||||
@@ -296,7 +585,6 @@ class LarkPlatformAdapter(Platform):
|
||||
else:
|
||||
abm.session_id = abm.sender.user_id
|
||||
|
||||
logger.debug(abm)
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, abm: AstrBotMessage) -> None:
|
||||
|
||||
Reference in New Issue
Block a user