diff --git a/nyx_bot/callbacks.py b/nyx_bot/callbacks.py index cc183ba..851d17d 100644 --- a/nyx_bot/callbacks.py +++ b/nyx_bot/callbacks.py @@ -5,23 +5,30 @@ from nio import ( AsyncClient, MatrixRoom, PowerLevels, + RoomGetEventError, RoomGetStateEventError, RoomMemberEvent, RoomMessageText, + RoomPutStateError, UnknownEvent, ) from nyx_bot.bot_commands import Command +from nyx_bot.chat_functions import send_text_to_room from nyx_bot.config import Config from nyx_bot.message_responses import Message from nyx_bot.storage import MatrixMessage, MembershipUpdates from nyx_bot.utils import ( + get_bot_event_type, get_replaces, get_reply_to, + hash_user_id, is_bot_event, make_datetime, + should_enable_join_confirm, should_record_message_content, strip_beginning_quote, + user_name, ) logger = logging.getLogger(__name__) @@ -124,12 +131,64 @@ class Callbacks: reacted_to = relation_dict.get("event_id") if reacted_to and relation_dict.get("rel_type") == "m.annotation": + await self._reaction(room, event, reacted_to) return logger.debug( f"Got unknown event with type to {event.type} from {event.sender} in {room.room_id}." ) + async def _reaction( + self, room: MatrixRoom, event: UnknownEvent, reacted_to_id: str + ) -> None: + """A reaction was sent to one of our messages. Let's send a reply acknowledging it. + + Args: + room: The room the reaction was sent in. + + event: The reaction event. + + reacted_to_id: The event ID that the reaction points to. + """ + logger.debug(f"Got reaction to {room.room_id} from {event.sender}.") + + # Get the original event that was reacted to + event_response = await self.client.room_get_event(room.room_id, reacted_to_id) + if isinstance(event_response, RoomGetEventError): + logger.warning( + "Error getting event that was reacted to (%s)", reacted_to_id + ) + return + reacted_to_event = event_response.event + if ( + is_bot_event(reacted_to_event) + and get_bot_event_type(reacted_to_event) == "join_confirm" + ): + content = reacted_to_event.source.get("content") + state_key = content.get("io.github.shadowrz.nyx_bot", {}).get("state_key") + required_reaction = hash_user_id(state_key) + + reaction_content = ( + event.source.get("content", {}).get("m.relates_to", {}).get("key") + ) + + if reaction_content == required_reaction: + state_resp = await self.client.room_get_state_event( + room.room_id, "m.room.power_levels" + ) + if isinstance(state_resp, RoomGetStateEventError): + logger.debug( + f"Failed to get power level data in room {room.display_name} ({room.room_id}). Stop processing." + ) + return + content = state_resp.content + events = content.get("events") + users = content.get("users") + del users[state_key] + await self.client.room_put_state( + room.room_id, "m.room.power_levels", {"events": events, "users": users} + ) + async def membership(self, room: MatrixRoom, event: RoomMemberEvent) -> None: timestamp = make_datetime(event.server_timestamp) MembershipUpdates.update_membership(room, event, timestamp) @@ -138,6 +197,8 @@ class Callbacks: "invite", "leave", ): + if not should_enable_join_confirm(self.room_features, room.room_id): + return content = event.content or {} name = content.get("displayname") logger.debug( @@ -152,10 +213,30 @@ class Callbacks: ) return content = state_resp.content - powers = PowerLevels( - events=content.get("events"), users=content.get("users") - ) + events = content.get("events") + events["m.reaction"] = -1 + users = content.get("users") + powers = PowerLevels(events=events, users=users) if not powers.can_user_send_state(self.client.user, "m.room.power_levels"): logger.debug( f"Bot is unable to update power levels in {room.display_name} ({room.room_id}). Stop processing." ) + return + users[event.state_key] = -1 + put_state_resp = await self.client.room_put_state( + room.room_id, "m.room.power_levels", {"events": events, "users": users} + ) + if isinstance(put_state_resp, RoomPutStateError): + logger.warn( + f"Failed to reconfigure power level: {put_state_resp.message}" + ) + return + await send_text_to_room( + self.client, + room.room_id, + f"新加群的用户 {user_name(room, event.state_key)} ({event.state_key}) 请用 Reaction {hash_user_id(event.state_key)} 回复本条消息", + notice=True, + markdown_convert=False, + literal_text=True, + extended_data={"type": "join_confirm", "state_key": event.state_key}, + ) diff --git a/nyx_bot/chat_functions.py b/nyx_bot/chat_functions.py index a5e14aa..a8899ed 100644 --- a/nyx_bot/chat_functions.py +++ b/nyx_bot/chat_functions.py @@ -5,7 +5,7 @@ import re import time from html import escape from io import BytesIO -from typing import Optional, Union +from typing import Any, Dict, Optional, Union import magic from markdown import markdown @@ -51,6 +51,7 @@ async def send_text_to_room( reply_to_event_id: Optional[str] = None, literal_text: Optional[bool] = False, literal_text_substitute: Optional[str] = None, + extended_data: Optional[Dict[Any, Any]] = None, ) -> Union[RoomSendResponse, ErrorResponse]: """Send text to a matrix room. @@ -160,10 +161,10 @@ async def send_text_to_room( content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} # Add custom data for tracking bot message. - content["io.github.shadowrz.nyx_bot"] = { - "in_reply_to": reply_to_event_id, - "type": "text", - } + content["io.github.shadowrz.nyx_bot"] = extended_data or {} + content["io.github.shadowrz.nyx_bot"]["in_reply_to"] = reply_to_event_id + if not content["io.github.shadowrz.nyx_bot"].get("type"): + content["io.github.shadowrz.nyx_bot"]["type"] = "text" try: return await client.room_send( diff --git a/nyx_bot/config.py b/nyx_bot/config.py index 491d628..d202876 100644 --- a/nyx_bot/config.py +++ b/nyx_bot/config.py @@ -123,6 +123,7 @@ class Config: "jerryxiao": False, "randomdraw": False, "record_messages": False, + "join_confirm": False, } if "jerryxiao" in room_features_dict: room_features_default["jerryxiao"] = room_features_dict["jerryxiao"] @@ -135,6 +136,9 @@ class Config: "record_messages" ] del room_features_dict["record_messages"] + if "join_confirm" in room_features_dict: + room_features_default["join_confirm"] = room_features_dict["join_confirm"] + del room_features_dict["join_confirm"] self.room_features = defaultdict(lambda: room_features_default) for k, v in room_features_dict.items(): if isinstance(v, dict): diff --git a/nyx_bot/utils.py b/nyx_bot/utils.py index 57e29e0..e367977 100644 --- a/nyx_bot/utils.py +++ b/nyx_bot/utils.py @@ -7,6 +7,7 @@ from random import Random from typing import Dict, Optional, Tuple from urllib.parse import unquote, urlparse +import xxhash from nio import AsyncClient, Event, MatrixRoom, RoomGetEventError, RoomMessageText from nyx_bot.errors import NyxBotRuntimeError @@ -82,12 +83,19 @@ def strip_beginning_quote(original: str) -> str: def get_reply_to(event: Event) -> Optional[str]: content = event.source.get("content") - reply_to = ((content.get("m.relates_to") or {}).get("m.in_reply_to") or {}).get( - "event_id" - ) + reply_to = content.get("m.relates_to", {}).get("m.in_reply_to", {}).get("event_id") return reply_to +def get_bot_event_type(event: Event) -> Optional[str]: + if is_bot_event(event): + content = event.source.get("content") + type = content.get("io.github.shadowrz.nyx_bot", {}).get("type") + return type + else: + return None + + def is_bot_event(event: Event) -> bool: content = event.source.get("content") return "io.github.shadowrz.nyx_bot" in content @@ -95,7 +103,7 @@ def is_bot_event(event: Event) -> bool: def get_replaces(event: Event) -> Optional[str]: content = event.source.get("content") - relates_to = content.get("m.relates_to") or {} + relates_to = content.get("m.relates_to", {}) rel_type = relates_to.get("rel_type") if rel_type == "m.replace": event_id = relates_to.get("event_id") @@ -237,6 +245,10 @@ def should_enable_randomdraw(room_features, room_id: str) -> bool: return room_features[room_id]["randomdraw"] +def should_enable_join_confirm(room_features, room_id: str) -> bool: + return room_features[room_id]["join_confirm"] + + # A structure for a Matrix UID. It also supports legacy UID formats. # First part: [\!-9\;-\~]+ # Matches legacy UIDs too. @@ -252,3 +264,11 @@ MATRIX_UID_RE = r"@([\!-9\;-\~]+):([0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}|\ def get_user_id_parts(user_id: str) -> Tuple[str, str]: uid, domain = re.match(MATRIX_UID_RE, user_id).groups() return (uid, domain) + + +REACTIONS = ["🎉", "🤣", "😃", "😋", "🥳", "🤔", "😅"] + + +def hash_user_id(user_id: str): + hash = xxhash.xxh64_intdigest(user_id) + return REACTIONS[hash % len(REACTIONS)] diff --git a/pyproject.toml b/pyproject.toml index 3790018..e02cf3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "python-dateutil", "wordcloud", "aiohttp", + "xxhash", ] classifiers=[ "License :: OSI Approved :: Apache Software License", diff --git a/requirements.txt b/requirements.txt index 16560b0..7859450 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ python-magic peewee python-dateutil wordcloud -aiohttp \ No newline at end of file +aiohttp +xxhash \ No newline at end of file diff --git a/sample.config.toml b/sample.config.toml index 149b266..a5ebdf3 100644 --- a/sample.config.toml +++ b/sample.config.toml @@ -60,6 +60,7 @@ encryption = false jerryxiao = false # Controls Jerry Xiao like feature. randomdraw = false record_messages = false # Controls recording message content. +join_confirm = false # Enable join confirming. # # Toogle this room's room features. # # You don't need to specify all of them.