diff --git a/python/ichika/client.py b/python/ichika/client.py index 2775474..1da1c1e 100644 --- a/python/ichika/client.py +++ b/python/ichika/client.py @@ -5,7 +5,17 @@ from .core import PlumbingClient, RawMessageReceipt from .message import _serialize_message as _serialize_msg -from .message.elements import At, AtAll, Audio, Face, FlashImage, Image, Reply, Text +from .message.elements import ( + At, + AtAll, + Audio, + Face, + FlashImage, + Image, + MusicShare, + Reply, + Text, +) class Client(PlumbingClient): @@ -39,13 +49,16 @@ async def _validate_chain(self, chain: MessageChain) -> MessageChain | Element: return chain async def _send_special_element(self, uin: int, kind: str, element: Element) -> RawMessageReceipt: - method = getattr(self, f"send_{kind}_{element.__class__.__name__.lower()}") if Audio._check(element): if element.raw is None: - sealed = (await getattr(self, f"upload_{kind}_audio")(uin, await element.fetch())).raw + uploader = self.upload_friend_audio if kind == "friend" else self.upload_group_audio + sealed = (await uploader(uin, await element.fetch())).raw else: sealed = element.raw - return await method(uin, sealed) + sender = self.send_friend_audio if kind == "friend" else self.send_group_audio + return await sender(uin, sealed) + if isinstance(element, MusicShare): + raise TypeError("音乐分享无法因发送后无法获得消息元数据,无法使用 send_xxx_message API 发送,请直接调用底层 API") raise TypeError(f"无法发送元素: {element!r}") async def send_group_message(self, uin: int, chain: MessageChain) -> RawMessageReceipt: diff --git a/python/ichika/core/__init__.pyi b/python/ichika/core/__init__.pyi index 8a3fdde..7b029e2 100644 --- a/python/ichika/core/__init__.pyi +++ b/python/ichika/core/__init__.pyi @@ -1,8 +1,11 @@ import asyncio from dataclasses import dataclass +from datetime import datetime from typing import Literal, TypeVar from typing_extensions import Any, TypeAlias +from ichika.message.elements import MusicShare + from ..client import Client from ..login import ( BaseLoginCredentialStore, @@ -230,6 +233,18 @@ class RawMessageReceipt: target: int """发送目标""" +@_internal_repr +class OCRText: + detected_text: str + confidence: int + polygon: VTuple[tuple[int, int]] | None + advanced_info: str + +@_internal_repr +class OCRResult: + texts: VTuple[OCRText] + language: str + __OnlineStatus: TypeAlias = ( # TODO: Wrapper tuple[int, str] # (face_index, wording) | tuple[ @@ -291,6 +306,7 @@ class PlumbingClient: ) -> None: ... async def get_other_clients(self) -> VTuple[OtherClientInfo]: ... async def modify_online_status(self, status: __OnlineStatus) -> None: ... + async def image_ocr(self, url: str, md5: str, width: int, height: int) -> OCRResult: ... # [impl 2] async def get_friend_list(self) -> FriendList: ... async def get_friend_list_raw(self) -> FriendList: ... @@ -323,11 +339,13 @@ class PlumbingClient: async def upload_friend_audio(self, uin: int, data: bytes) -> dict[str, Any]: ... async def upload_group_image(self, uin: int, data: bytes) -> dict[str, Any]: ... async def upload_group_audio(self, uin: int, data: bytes) -> dict[str, Any]: ... + async def send_friend_audio(self, uin: int, audio: _SealedAudio) -> RawMessageReceipt: ... + async def send_group_audio(self, uin: int, audio: _SealedAudio) -> RawMessageReceipt: ... + async def send_friend_music_share(self, uin: int, share: MusicShare) -> None: ... + async def send_group_music_share(self, uin: int, share: MusicShare) -> None: ... # [impl 6] async def send_friend_message(self, uin: int, chain: list[dict[str, Any]]) -> RawMessageReceipt: ... - async def send_friend_audio(self, uin: int, audio: _SealedAudio) -> RawMessageReceipt: ... async def send_group_message(self, uin: int, chain: list[dict[str, Any]]) -> RawMessageReceipt: ... - async def send_group_audio(self, uin: int, audio: _SealedAudio) -> RawMessageReceipt: ... async def recall_friend_message(self, uin: int, time: int, seq: int, rand: int) -> None: ... async def recall_group_message(self, uin: int, seq: int, rand: int) -> None: ... async def modify_group_essence(self, uin: int, seq: int, rand: int, flag: bool) -> None: ... @@ -342,3 +360,24 @@ class PlumbingClient: def face_id_from_name(name: str) -> int | None: ... def face_name_from_id(id: int) -> str: ... +@_internal_repr +class MessageSource: + """消息元信息""" + + seqs: tuple[int, ...] + """消息的 SEQ + 建议搭配聊天类型与上下文 ID (例如 `("group", 123456, seq)`)作为索引的键 + """ + rands: tuple[int, ...] + """消息的随机信息,撤回需要""" + time: datetime + """消息发送时间""" + +@_internal_repr +class FriendInfo: + """事件中的好友信息""" + + uin: int + """好友账号""" + nickname: str + """好友实际昵称""" diff --git a/python/ichika/core/events/__init__.pyi b/python/ichika/core/events/__init__.pyi deleted file mode 100644 index e69de29..0000000 diff --git a/python/ichika/core/events/structs.pyi b/python/ichika/core/events/structs.pyi deleted file mode 100644 index 7ef10aa..0000000 --- a/python/ichika/core/events/structs.pyi +++ /dev/null @@ -1,27 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime - -_internal_repr = dataclass(frozen=True, init=False, eq=False) - -@_internal_repr -class MessageSource: - """消息元信息""" - - seqs: tuple[int, ...] - """消息的 SEQ - - 建议搭配聊天类型与上下文 ID (例如 `("group", 123456, seq)`)作为索引的键 - """ - rands: tuple[int, ...] - """消息的随机信息,撤回需要""" - time: datetime - """消息发送时间""" - -@_internal_repr -class FriendInfo: - """事件中的好友信息""" - - uin: int - """好友账号""" - nickname: str - """好友实际昵称""" diff --git a/python/ichika/events.py b/python/ichika/events.py index 827e40d..46f95c6 100644 --- a/python/ichika/events.py +++ b/python/ichika/events.py @@ -4,8 +4,7 @@ from graia.amnesia.message import MessageChain -from ichika.core import Group, Member -from ichika.core.events.structs import FriendInfo, MessageSource +from ichika.core import FriendInfo, Group, Member, MessageSource class LoginEvent(TypedDict): diff --git a/python/ichika/message/__init__.py b/python/ichika/message/__init__.py index acd8c62..5687735 100644 --- a/python/ichika/message/__init__.py +++ b/python/ichika/message/__init__.py @@ -6,14 +6,14 @@ from graia.amnesia.message.element import Element, Unknown from loguru import logger -from .elements import TYPE_MAP +from .elements import DESERIALIZE_INV from .serializer import SERIALIZE_INV -def deserialize_message(elements: list[dict[str, Any]]) -> MessageChain: +def _deserialize_message(elements: list[dict[str, Any]]) -> MessageChain: elem_seq: list[Element] = [] for e_data in elements: - cls = TYPE_MAP.get(e_data.pop("type"), None) + cls = DESERIALIZE_INV.get(e_data.pop("type"), None) if cls is None: logger.warning(f"未知元素: {e_data!r}") elem_seq.append(Unknown("Unknown", e_data)) diff --git a/python/ichika/message/elements.py b/python/ichika/message/elements.py index cf2e135..da4d336 100644 --- a/python/ichika/message/elements.py +++ b/python/ichika/message/elements.py @@ -7,7 +7,7 @@ from enum import Enum from functools import total_ordering from io import BytesIO -from typing import Generic, Literal, Optional +from typing import Callable, Generic, Literal, Optional from typing_extensions import Self, TypeAlias, TypeGuard, TypeVar import aiohttp @@ -108,9 +108,33 @@ def __str__(self) -> str: return f"[表情: {self.name}]" +@dataclass +class MusicShare(Element): + """音乐分享 + + 音乐分享本质为 “小程序” + 但是可以用不同方式发送 + 并且风控几率较小 + """ + + kind: Literal["QQ", "Netease", "Migu", "Kugou", "Kuwo"] + title: str + summary: str + jump_url: str + picture_url: str + music_url: str + brief: str + + @dataclass class LightApp(Element): + """小程序 + + 本框架不辅助音乐分享外的小程序构造与发送 + """ + content: str + """JSON 内容""" def __str__(self) -> str: return "[小程序]" @@ -275,7 +299,7 @@ def __repr__(self) -> str: return f"MarketFace(name={self.name})" -TYPE_MAP = { +DESERIALIZE_INV: dict[str, Callable[..., Element]] = { cls.__name__: cls for cls in ( Reply, @@ -293,3 +317,36 @@ def __repr__(self) -> str: Audio, ) } + +__MUSIC_SHARE_APPID_MAP: dict[int, Literal["QQ", "Netease", "Migu", "Kugou", "Kuwo"]] = { + 100497308: "QQ", + 100495085: "Netease", + 1101053067: "Migu", + 205141: "Kugou", + 100243533: "Kuwo", +} + + +def _light_app_deserializer(**data) -> Element: + import json + from contextlib import suppress + + with suppress(ValueError, KeyError): + # MusicShare resolver + # https://github.com/mamoe/mirai/blob/893fb3e9f653623056f9c4bff73b4dac957cd2a2/mirai-core/src/commonMain/kotlin/message/data/lightApp.kt + app_data = json.loads(data["content"]) + music_info = app_data["meta"]["music"] + return MusicShare( + kind=__MUSIC_SHARE_APPID_MAP[app_data["extra"]["appid"]], + title=music_info["title"], + summary=music_info["desc"], + jump_url=music_info["jumpUrl"], + picture_url=music_info["preview"], + music_url=music_info["musicUrl"], + brief=data["prompt"], + ) + + return LightApp(content=data["content"]) + + +DESERIALIZE_INV["LightApp"] = _light_app_deserializer diff --git a/python/ichika/message/serializer.py b/python/ichika/message/serializer.py index 5b98b5d..b836acb 100644 --- a/python/ichika/message/serializer.py +++ b/python/ichika/message/serializer.py @@ -15,6 +15,7 @@ FingerGuessing, FlashImage, Image, + LightApp, MarketFace, Reply, ) @@ -48,17 +49,18 @@ def wrapper(elem: Elem_T) -> dict[str, Any]: _serialize(FingerGuessing)(lambda t: {"choice": t.choice.name}) _serialize(Face)(lambda t: {"index": t.index}) _serialize(MarketFace)(lambda t: {"raw": t.raw}) +_serialize(LightApp)(lambda t: {"content": t.content}) @_serialize(Image) -def _(i: Image): - if i.raw is None: +def _serialize_image(elem: Image): + if elem.raw is None: raise ValueError - return {"raw": i.raw} + return {"raw": elem.raw} @_serialize(FlashImage) -def _(i: FlashImage): - if i.raw is None: +def _serialize_flash_image(elem: FlashImage): + if elem.raw is None: raise ValueError - return {"raw": i.raw} + return {"raw": elem.raw} diff --git a/src/client/mod.rs b/src/client/mod.rs index e161ad8..80fd598 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -181,6 +181,23 @@ impl PlumbingClient { Ok(()) }) } + + pub fn image_ocr<'py>( + &self, + py: Python<'py>, + url: String, + md5: String, + weight: i32, + height: i32, + ) -> PyResult<&'py PyAny> { + let client = self.client.clone(); + py_future(py, async move { + let resp = client + .image_ocr(url, md5, weight * height, weight, height) + .await?; + Ok(OCRResult::from(resp).obj()) + }) + } } #[pymethods] @@ -537,21 +554,18 @@ impl PlumbingClient { })?) }) } -} -#[pymethods] -impl PlumbingClient { - pub fn send_friend_message<'py>( + pub fn send_friend_audio<'py>( &self, py: Python<'py>, uin: i64, - chain: &'py PyList, + audio: PyObject, ) -> PyResult<&'py PyAny> { let client = self.client.clone(); - let chain = deserialize_message_chain(chain)?; + let ptt = audio.extract::(py)?.inner; py_future(py, async move { let ricq::structs::MessageReceipt { seqs, rands, time } = - client.send_friend_message(uin, chain).await?; + client.send_friend_audio(uin, FriendAudio(ptt)).await?; Ok(Python::with_gil(|py| RawMessageReceipt { seqs: PyTuple::new(py, seqs).into_py(py), rands: PyTuple::new(py, rands).into_py(py), @@ -562,7 +576,7 @@ impl PlumbingClient { }) } - pub fn send_friend_audio<'py>( + pub fn send_group_audio<'py>( &self, py: Python<'py>, uin: i64, @@ -572,18 +586,59 @@ impl PlumbingClient { let ptt = audio.extract::(py)?.inner; py_future(py, async move { let ricq::structs::MessageReceipt { seqs, rands, time } = - client.send_friend_audio(uin, FriendAudio(ptt)).await?; + client.send_group_audio(uin, GroupAudio(ptt)).await?; Ok(Python::with_gil(|py| RawMessageReceipt { seqs: PyTuple::new(py, seqs).into_py(py), rands: PyTuple::new(py, rands).into_py(py), time, - kind: "friend".into(), + kind: "group".into(), target: uin, })) }) } - pub fn send_group_message<'py>( + pub fn send_friend_music_share<'py>( + &self, + py: Python<'py>, + uin: i64, + share: MusicShareParam, + ) -> PyResult<&'py PyAny> { + let client = self.client.clone(); + let (music_share, music_version) = share.try_into()?; + py_future(py, async move { + client + .send_friend_music_share(uin, music_share, music_version) + .await + .py_res()?; + Ok(()) + }) + } + + pub fn send_group_music_share<'py>( + &self, + py: Python<'py>, + uin: i64, + share: MusicShareParam, + ) -> PyResult<&'py PyAny> { + // TODO: Allow returning MessageSource + + let client = self.client.clone(); + let (music_share, music_version) = share.try_into()?; + py_future(py, async move { + client + .send_group_music_share(uin, music_share, music_version) + .await + .py_res()?; + // TODO: Immediate listen hook + // LINK: https://github.com/Mrs4s/MiraiGo/blob/f8d9841755b579f7c95ed918d23b767e3854553a/client/richmsg.go#L71 + Ok(()) + }) + } +} + +#[pymethods] +impl PlumbingClient { + pub fn send_friend_message<'py>( &self, py: Python<'py>, uin: i64, @@ -593,28 +648,28 @@ impl PlumbingClient { let chain = deserialize_message_chain(chain)?; py_future(py, async move { let ricq::structs::MessageReceipt { seqs, rands, time } = - client.send_group_message(uin, chain).await?; + client.send_friend_message(uin, chain).await?; Ok(Python::with_gil(|py| RawMessageReceipt { seqs: PyTuple::new(py, seqs).into_py(py), rands: PyTuple::new(py, rands).into_py(py), time, - kind: "group".into(), + kind: "friend".into(), target: uin, })) }) } - pub fn send_group_audio<'py>( + pub fn send_group_message<'py>( &self, py: Python<'py>, uin: i64, - audio: PyObject, + chain: &'py PyList, ) -> PyResult<&'py PyAny> { let client = self.client.clone(); - let ptt = audio.extract::(py)?.inner; + let chain = deserialize_message_chain(chain)?; py_future(py, async move { let ricq::structs::MessageReceipt { seqs, rands, time } = - client.send_group_audio(uin, GroupAudio(ptt)).await?; + client.send_group_message(uin, chain).await?; Ok(Python::with_gil(|py| RawMessageReceipt { seqs: PyTuple::new(py, seqs).into_py(py), rands: PyTuple::new(py, rands).into_py(py), @@ -672,7 +727,6 @@ impl PlumbingClient { Ok(()) }) } - // TODO: Send audio } #[pymethods] diff --git a/src/client/structs.rs b/src/client/structs.rs index 1e6823e..b0d91fb 100644 --- a/src/client/structs.rs +++ b/src/client/structs.rs @@ -1,6 +1,11 @@ +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::*; use pyo3_repr::PyRepr; +use ricq::structs::{MusicShare, MusicVersion}; +use ricq_core::command::oidb_svc::OcrResponse; + +use crate::utils::py_use; #[pyclass(get_all)] #[derive(PyRepr, Clone)] pub struct AccountInfo { @@ -28,6 +33,52 @@ pub struct RawMessageReceipt { pub target: i64, } +#[pyclass(get_all)] +#[derive(PyRepr, Clone)] +pub struct OCRResult { + pub texts: Py, // PyTuple + pub language: String, +} + +#[pyclass(get_all)] +#[derive(PyRepr, Clone)] +pub struct OCRText { + pub detected_text: String, + pub confidence: i32, + pub polygon: Option>, // PyTuple<(i32, i32))> + pub advanced_info: String, +} + +impl From for OCRResult { + fn from(value: OcrResponse) -> Self { + py_use(|py| { + let OcrResponse { texts, language } = value; + let text_iter = texts.into_iter().map(|txt| { + let polygon = txt.polygon.map(|poly| { + PyTuple::new( + py, + poly.coordinates + .into_iter() + .map(|coord| (coord.x, coord.y).to_object(py)), + ) + .into_py(py) + }); + OCRText { + detected_text: txt.detected_text, + confidence: txt.confidence, + polygon, + advanced_info: txt.advanced_info, + } + .into_py(py) + }); + OCRResult { + texts: PyTuple::new(py, text_iter).into_py(py), + language, + } + }) + } +} + #[derive(FromPyObject)] pub enum OnlineStatusParam { #[pyo3(annotation = "tuple[bool, int]")] @@ -56,3 +107,58 @@ impl From for ricq::structs::Status { } } } + +#[derive(FromPyObject)] +pub struct MusicShareParam { + #[pyo3(attribute)] + kind: String, + #[pyo3(attribute)] + title: String, + #[pyo3(attribute)] + summary: String, + #[pyo3(attribute)] + jump_url: String, + #[pyo3(attribute)] + picture_url: String, + #[pyo3(attribute)] + music_url: String, + #[pyo3(attribute)] + brief: String, +} + +impl TryFrom for (MusicShare, MusicVersion) { + type Error = PyErr; + + fn try_from(value: MusicShareParam) -> Result { + let MusicShareParam { + kind, + title, + summary, + jump_url, + picture_url, + music_url, + brief, + } = value; + let version = match kind.as_str() { + "QQ" => MusicVersion::QQ, + "Netease" => MusicVersion::NETEASE, + "Migu" => MusicVersion::MIGU, + "Kugou" => MusicVersion::KUGOU, + "Kuwo" => MusicVersion::KUWO, + platform => { + return Err(PyValueError::new_err(format!( + "无法识别的音乐平台: {platform}" + ))) + } + }; + let share = MusicShare { + title, + brief, + summary, + url: jump_url, + picture_url, + music_url, + }; + Ok((share, version)) + } +} diff --git a/src/lib.rs b/src/lib.rs index bc89c8e..8cbd060 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,36 +48,12 @@ pub fn core(py: Python, m: &PyModule) -> PyResult<()> { client::group::Member, client::structs::AccountInfo, client::structs::OtherClientInfo, - client::structs::RawMessageReceipt + client::structs::RawMessageReceipt, + client::structs::OCRResult, + client::structs::OCRText, + events::structs::MessageSource, + events::structs::FriendInfo ); - register_event_module(py, m)?; loguru::init(m)?; Ok(()) } - -fn register_event_module(py: Python, parent: &PyModule) -> PyResult<()> { - let m = PyModule::new(py, "ichika.core.events")?; - parent.add_submodule(m)?; - parent.add("events", m)?; - // See https://github.com/PyO3/pyo3/issues/759 - py.import("sys")? - .getattr("modules")? - .set_item("ichika.core.events", m)?; - register_event_structs_module(py, m)?; - Ok(()) -} - -fn register_event_structs_module(py: Python, parent: &PyModule) -> PyResult<()> { - let m = PyModule::new(py, "ichika.core.events.structs")?; - add_batch!(@cls m, - crate::events::structs::MessageSource, - crate::events::structs::FriendInfo - ); - parent.add_submodule(m)?; - parent.add("structs", m)?; - // See https://github.com/PyO3/pyo3/issues/759 - py.import("sys")? - .getattr("modules")? - .set_item("ichika.core.events.structs", m)?; - Ok(()) -} diff --git a/src/login/mod.rs b/src/login/mod.rs index 4afce58..236a7b4 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -252,8 +252,8 @@ async fn handle_device_lock( .message .as_ref() .map_or_else(|| "请解锁设备锁进行验证", |msg| msg.as_str()); - let verify_url = data.verify_url.as_ref().map_or( - Err(exc::RICQError::new_err("无法获取验证地址")), + let verify_url = data.verify_url.as_ref().map_or_else( + || Err(exc::RICQError::new_err("无法获取验证地址")), |url| Ok(url.clone()), )?; tracing::info!("{:?}", data.clone()); @@ -317,8 +317,8 @@ async fn password_login_process( .await?; } LoginResponse::NeedCaptcha(LoginNeedCaptcha { ref verify_url, .. }) => { - let verify_url = verify_url.as_ref().map_or( - Err(exc::RICQError::new_err("无法获取验证地址")), + let verify_url = verify_url.as_ref().map_or_else( + || Err(exc::RICQError::new_err("无法获取验证地址")), |url| Ok(url.clone()), )?; let ticket = diff --git a/src/message/convert.rs b/src/message/convert.rs index c8b2930..c45de63 100644 --- a/src/message/convert.rs +++ b/src/message/convert.rs @@ -111,6 +111,12 @@ pub fn serialize_element(py: Python, e: RQElem) -> PyResult> { } } }, + RQElem::LightApp(app) => { + dict! {py, + type: "LightApp", + content: app.content + } + } RQElem::Other(_) => { return Ok(None); } @@ -159,7 +165,7 @@ static_py_fn!( py_deserialize, __py_deserialize_cell, "ichika.message", - ["deserialize_message"] + ["_deserialize_message"] ); pub fn serialize_as_py_chain(py: Python, chain: MessageChain) -> PyResult // PyMessageChain