dm-honeypot-bot/dm_honeypot_bot/__init__.py

231 lines
10 KiB
Python

# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
# If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
import asyncio
import base64
import hashlib
import random
import time
import typing
import enum
from typing import Type
from maubot.handlers import command, event
from mautrix.types import EventID, EventType, Membership, MessageEvent, MessageType, RoomID, StateEvent, UserID
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from maubot import Plugin
M_POLICY_RULE_USER = EventType("m.policy.rule.user", EventType.Class.STATE)
def generate_policy_state_key(target: UserID) -> str:
return base64.b64encode(hashlib.sha256(f"{target}m.ban".encode()).digest()).decode("utf-8")
class DetectionProfileName(enum.Enum):
AGGRESSIVE = "aggressive"
NORMAL = "normal"
CONSERVATIVE = "conservative"
class DetectionProfile:
def __init__(self, name: DetectionProfileName):
self.send_receipts = name in (DetectionProfileName.NORMAL, DetectionProfileName.AGGRESSIVE)
self.send_typing = name == DetectionProfileName.AGGRESSIVE
self.require_direct = name == DetectionProfileName.CONSERVATIVE
self.account_age_bypass = name in (DetectionProfileName.NORMAL, DetectionProfileName.CONSERVATIVE)
class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("admins")
helper.copy("policy_list_room_id")
helper.copy("policy_list_reason")
helper.copy("profile")
helper.copy("explicit_rooms")
@property
def admins(self) -> list[UserID]:
"""All user IDs allowed to control the bot"""
return self.get("admins", []) or []
@property
def policy_list_room_id(self) -> RoomID:
return self.get("policy_list_room_id", "") or ""
@property
def policy_list_reason(self) -> str:
return self.get("policy_list_reason", "automated ban: {trigger_reason}") or ""
@property
def profile(self) -> DetectionProfileName:
return DetectionProfileName(self.get("profile", "normal") or "normal")
@property
def explicit_rooms(self) -> list[RoomID]:
return self.get("explicit_rooms", []) or []
class DMHoneypotBot(Plugin):
if typing.TYPE_CHECKING:
config: Config
receipt_queue: asyncio.Queue | None
def profile(self) -> DetectionProfile:
return DetectionProfile(self.config.profile)
async def start(self) -> None:
self.config.load_and_update()
self.receipt_queue = asyncio.Queue()
asyncio.create_task(self.queue_worker(), name=f"queue-worker-{self.id}")
self.log.info("Loaded")
async def stop(self) -> None:
if hasattr(self.receipt_queue, "shutdown"):
self.receipt_queue.shutdown(True)
else:
self.receipt_queue.put_nowait("blow yourself up you foul beast")
@classmethod
def get_config_class(cls) -> Type[BaseProxyConfig]:
return Config
async def queue_worker(self):
log = self.log.getChild("queue_worker")
last_receipt_rooms: dict[RoomID, float | int] = {}
while self.receipt_queue is not None:
try:
log.debug("waiting for job")
room_id, event_id = await self.receipt_queue.get()
log.debug("got job: %s/%s", room_id, event_id)
except (asyncio.QueueShutDown, ValueError):
log.info("shutting down")
return
# If the last receipt was sent <5 seconds ago, we're "actively" "following" the "conversation".
# Otherwise, we are "not checking the chat" that often, so use a longer timeout.
last_receipt_timestamp = last_receipt_rooms.get(room_id, 0)
# This sleep blocks the whole queue loop which means it's a "global", sleep, but in theory
# that's more realistic anyway?
if time.time() - last_receipt_timestamp > random.randint(10, 30):
log.debug("Not paying attention in %s", room_id)
sleep_seconds = random.randint(30, 120)
else:
log.debug("Bursting in %s", room_id)
sleep_seconds = random.randint(300, 3000) / 1000
if self.profile().send_receipts:
log.debug("Issuing receipt in %.3f seconds", sleep_seconds)
await asyncio.sleep(sleep_seconds)
log.debug("Sending a read receipt to %s in %s", event_id, room_id)
await self.client.send_receipt(room_id, event_id)
else:
log.debug("Not sending read receipts in this profile")
if not self.profile().send_typing:
log.debug("Not sending typing indicators in this profile")
continue
typing_timeout_ms = random.randint(1, 15000)
if typing_timeout_ms >= 5000:
log.info(
"Sending a typing indicator for %d milliseconds in %s: %s",
typing_timeout_ms,
room_id,
event_id
)
await self.client.set_typing(room_id, typing_timeout_ms)
last_receipt_rooms[room_id] = time.time()
async def is_allowed(self, user_id: UserID, room_id: RoomID | None) -> bool:
if user_id == self.client.mxid or user_id in self.config.admins:
return True
if room_id is not None:
power_levels = (
await self.client.state_store.get_power_levels(room_id)
or await self.client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
)
users_default = power_levels.users_default if power_levels else 0
user_pl = power_levels.users.get(user_id, power_levels.users_default) if power_levels else users_default
return user_pl > users_default
return False
@event.on(EventType.ROOM_MEMBER)
async def on_room_member(self, evt: StateEvent):
if evt.state_key != self.client.mxid or evt.sender == self.client.mxid:
self.log.debug("Ignoring irrelevant membership (state key %s, sender %s)", evt.state_key, evt.sender)
return
if evt.content.membership != Membership.INVITE:
self.log.debug("Ignoring non-invite membership: %s (%r)", evt.event_id, evt)
return
if evt.unsigned is None or evt.unsigned.invite_room_state is None:
self.log.debug("Ignoring invite membership with no invite room state (%r)", evt)
return
if self.profile().require_direct and not evt.content.is_direct:
self.log.debug("Ignoring non-direct invite membership: %s (%r)", evt.event_id, evt)
if await self.is_allowed(evt.sender, None):
self.log.debug("Received invite from admin user, will auto-join")
# This is an invite from an admin, we should accept it
await asyncio.sleep(2)
await self.client.join_room(evt.room_id, [evt.sender.split(":", 1)[1]])
return
self.log.info("Banning user %s for inviting me to %s", evt.sender, evt.room_id)
# This is an invite from a non-admin, issue a ban and reject after a minute or so
await self.client.send_state_event(
self.config.policy_list_room_id,
M_POLICY_RULE_USER,
{
"entity": evt.sender,
"reason": self.config.policy_list_reason.format(trigger_reason="unsolicited DM"),
"recommendation": "m.ban"
},
generate_policy_state_key(evt.sender),
)
async def reject_bg():
self.log.debug("Waiting a few seconds seconds before rejecting invite to %s", evt.room_id)
await asyncio.sleep(random.randint(5, 30))
self.log.info("Rejecting invite to %s from %s", evt.room_id, evt.sender)
try:
await self.client.leave_room(evt.room_id)
except Exception:
pass
asyncio.create_task(reject_bg(), name=f"reject-invite-{evt.sender}-{evt.room_id}")
@command.passive(r"^(hi|he(llo|y(a)?))", msgtypes=[MessageType.TEXT], case_insensitive=True)
async def on_hello(self, evt: MessageEvent, _):
if await self.is_allowed(evt.sender, evt.room_id):
self.log.debug("Ignoring hello from admin in %s", evt.room_id)
return
if evt.room_id not in self.config.explicit_rooms and len(self.config.explicit_rooms) > 0:
self.log.debug("Ignoring event not in a room I care about: %s", evt.room_id)
return
if self.profile().account_age_bypass:
self.log.debug("Checking if hello sender is older than 3 days: %s", evt.sender)
# If the user's last membership was more than a few days ago, it's safe to assume they aren't spamming.
state: StateEvent = await self.client.get_state_event( # type: ignore
evt.room_id,
EventType.ROOM_MEMBER,
evt.sender,
format="event"
)
age = ((evt.timestamp or 0) - (state.timestamp or 0)) / 1000
if age > 259200: # 3 days
self.log.debug("Ignoring hello message from %s as their last membership was >3 days ago: %.2f seconds", evt.sender, age)
return
else:
self.log.debug("Sender is <3 days ago: %s", evt.sender)
self.log.info("Waving to %s due to hello %s in %s: %r", evt.sender, evt.event_id, evt.room_id, evt.content.body)
await asyncio.sleep(random.randint(3, 60))
await self.client.react(evt.room_id, evt.event_id, "\N{Waving Hand Sign}")
@event.on(EventType.ROOM_MESSAGE)
async def on_room_message(self, evt: MessageEvent):
if await self.is_allowed(evt.sender, evt.room_id):
self.log.debug("Ignoring message from admin in %s", evt.room_id)
return
if evt.room_id not in self.config.explicit_rooms and len(self.config.explicit_rooms) > 0:
self.log.debug("Ignoring event not in a room I care about: %s", evt.room_id)
return
self.receipt_queue.put_nowait((evt.room_id, evt.event_id))
self.log.debug("Queued up receipt job in %s/%s", evt.room_id, evt.event_id)