diff --git a/hertavilla/apis/message.py b/hertavilla/apis/message.py index 6657839..3a3296b 100644 --- a/hertavilla/apis/message.py +++ b/hertavilla/apis/message.py @@ -3,6 +3,7 @@ import json from hertavilla.apis.internal import _BaseAPIMixin +from hertavilla.message.component import Panel from hertavilla.message.internal import MsgContentInfo from hertavilla.utils import MsgEncoder @@ -97,3 +98,33 @@ async def recall_message( "msg_time": msg_time, }, ) + + async def create_component_template( + self, + villa_id: int, + panel: Panel, + ) -> int: + """创建消息组件模板,创建成功后会返回 template_id, + 发送消息时,可以使用 template_id 填充 component_board + + Args: + villa_id (int): 大别野 id + panel (Panel): 消息组件面板 + + Returns: + int: 组件模板id + """ + if template_id := (panel_dict := panel.to_dict()).get("template_id"): + return template_id + return int( + ( + await self.base_request( + "/createComponentTemplate", + "POST", + villa_id, + data={ + "panel": json.dumps(panel_dict), + }, + ) + )["template_id"], + ) diff --git a/hertavilla/event.py b/hertavilla/event.py index 44584d9..47db270 100644 --- a/hertavilla/event.py +++ b/hertavilla/event.py @@ -252,7 +252,7 @@ class AuditCallbackEvent(Event): bot_tpl_id: str """机器人 id""" - room_id: int | None = None + room_id: Optional[int] = None """房间 id(和审核接口调用方传入的值一致)""" user_id: int @@ -271,6 +271,31 @@ def compare(self, audit_id: str, pass_through: str | None = None) -> bool: return self.audit_id == audit_id and self.pass_through == pass_through +class ClickMsgComponentEvent(Event): + type: Literal[7] + + room_id: int + """房间 id""" + + uid: int + """用户 id""" + + msg_uid: str + """消息 id""" + + bot_msg_id: str = "" + """如果消息从属于机器人,则该字段不为空字符串""" + + component_id: str + """机器人自定义的组件id""" + + template_id: int = 0 + """如果该组件模板为已创建模板,则template_id不为0""" + + extra: str = "" + """机器人自定义透传信息""" + + def parse_event(payload: dict[str, Any]) -> Event: type_: int = payload["type"] cls_, name = events[type_] diff --git a/hertavilla/message/chain.py b/hertavilla/message/chain.py index 98743da..4d90ad7 100644 --- a/hertavilla/message/chain.py +++ b/hertavilla/message/chain.py @@ -5,6 +5,7 @@ import sys from typing import TYPE_CHECKING, Iterable, List +from hertavilla.message.component import Panel from hertavilla.message.image import ( Image, ImageMsgContentInfo, @@ -71,19 +72,22 @@ def extend(self, obj: Iterable[_Segment]) -> Self: self.append(segment) return self - async def to_content_json( + async def to_content_json( # noqa: PLR0912 self, bot: VillaBot, ) -> tuple[MsgContentInfo, str]: text_entities = [] image = [] posts = [] + panel: Panel | None = None for segment in self: if isinstance(segment, Image): image.append(image_to_content(segment)) elif isinstance(segment, Post): posts.append(post_to_content(segment)) + elif isinstance(segment, Panel): + panel = segment else: text_entities.append(segment) @@ -127,7 +131,10 @@ async def to_content_json( "When post and text are present at the same time, " "the post will not be displayed", ) - return await text_to_content(text_entities, bot, image), "MHY:Text" + return ( + await text_to_content(text_entities, bot, image, panel), + "MHY:Text", + ) async def get_text( self, diff --git a/hertavilla/message/component.py b/hertavilla/message/component.py new file mode 100644 index 0000000..325e93c --- /dev/null +++ b/hertavilla/message/component.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from enum import IntEnum +from typing import TYPE_CHECKING, Any, List, Literal + +from hertavilla.message.internal import _Segment + +from pydantic import BaseModel, validator + +if TYPE_CHECKING: + from hertavilla.bot import VillaBot + + +SMALL_MAX = 4 +MID_MAX = 2 +BIG_MAX = 1 + + +class Component(BaseModel): + id: str # noqa: A003 + """组件id,由机器人自定义,不能为空字符串。面板内的id需要唯一""" + + text: str + """组件展示文本, 不能为空""" + + type: int # noqa: A003 + """组件类型,目前支持 type=1 按钮组件,未来会扩展更多组件类型""" + + need_callback: bool = True + """是否订阅该组件的回调事件""" + + extra: str = "" + """组件回调透传信息,由机器人自定义""" + + +Comp = Component + + +class ComponentGroup(BaseModel): + __root__: List[Component] + + def __init__(self, *components: Component): + super().__init__(__root__=components) + + +CGroup = ComponentGroup + + +class ComponentGroupList(BaseModel): + __root__: List[CGroup] + + def __init__(self, *groups): + super().__init__(__root__=groups) + + +CGroupList = ComponentGroupList + + +class SGroup(CGroup): + def __init__(self, *components: Component): + super().__init__(*components) + + @validator("__root__") + def check(cls, v: List[Component]) -> List[Component]: + if len(v) > SMALL_MAX: + raise ValueError( + f"small component group max length is {SMALL_MAX}", + ) + return v + + +class MGroup(CGroup): + def __init__(self, *components: Component): + super().__init__(*components) + + @validator("__root__") + def check(cls, v: List[Component]) -> List[Component]: + if len(v) > MID_MAX: + raise ValueError(f"mid component group max length is {MID_MAX}") + return v + + +class BGroup(CGroup): + def __init__(self, *components: Component): + super().__init__(*components) + + @validator("__root__") + def check(cls, v: List[Component]) -> List[Component]: + if len(v) > BIG_MAX: + raise ValueError(f"big component group max length is {BIG_MAX}") + return v + + +LGroup = BGroup + + +class Panel(_Segment): + def __init__( + self, + template_id: int | None = None, + small: CGroupList | None = None, + mid: CGroupList | None = None, + big: CGroupList | None = None, + ) -> None: + if not template_id and not (small or mid or big): + raise ValueError( + "At least one of template_id, component group must be set", + ) + self.small = CGroupList() if small is None else small + self.mid = CGroupList() if mid is None else mid + self.big = CGroupList() if big is None else big + + self.template_id = template_id + + async def get_text(self, _: "VillaBot") -> str: + return "[Panel]" + + def to_dict( + self, + ) -> dict[str, Any]: + if (template_id := self.template_id) is not None: + return {"template_id": template_id} + return { + "small_component_group_list": self.small.dict()["__root__"], + "mid_component_group_list": self.mid.dict()["__root__"], + "big_component_group_list": self.big.dict()["__root__"], + } + + # def insert(self, target: Literal["small", "mid", "big"], index: tuple[int, int], component: Component) -> None: # noqa: E501 + + +class ButtonType(IntEnum): + """组件交互类型""" + + CALLBACK = 1 + """回传型""" + INPUT = 2 + """输入型""" + LINK = 3 + """跳转型""" + + +class Button(Component): + type: Literal[1] = 1 # noqa: A003 + + c_type: ButtonType + """组件交互类型""" + + input: str = "" # noqa: A003 + """如果交互类型为输入型,则需要在该字段填充输入内容,不能为空""" + + link: str = "" + """如果交互类型为跳转型,需要在该字段填充跳转链接,不能为空""" + + need_token: bool = False + """对于跳转链接来说,如果希望携带用户信息token,则need_token设置为true""" + + @validator("input") + def check_input(cls, v, values): + if values["c_type"] == ButtonType.INPUT and not v: + raise ValueError("input is required") + return v + + @validator("link") + def check_link(cls, v, values): + if values["c_type"] == ButtonType.LINK and not v: + raise ValueError("link is required") + return v diff --git a/hertavilla/message/text.py b/hertavilla/message/text.py index a3bb35a..3638ed9 100644 --- a/hertavilla/message/text.py +++ b/hertavilla/message/text.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any, List, Literal, Optional, cast +from hertavilla.message.component import Panel from hertavilla.message.image import ImageMsgContent from hertavilla.message.internal import MsgContent, MsgContentInfo, _Segment from hertavilla.typing import TypedDict @@ -19,6 +20,7 @@ class TextMsgContentInfo(MsgContentInfo): content: TextMsgContent mentionedInfo: Optional[MentionedInfo] quote: Optional[QuoteInfo] + panel: Optional[dict] class QuoteInfo(TypedDict): @@ -177,6 +179,7 @@ async def text_to_content( text_entities: list[_TextEntity], bot: VillaBot, image: list[ImageMsgContent] | None = None, + panel: Panel | None = None, ) -> TextMsgContentInfo: texts: list[str] = [] entities: list[EntityDict] = [] @@ -247,4 +250,5 @@ async def text_to_content( ), "quote": quote, "mentionedInfo": mentioned_info, + "panel": panel.to_dict() if panel is not None else None, }