Currently, the last_receipts map is only updated after typing is sent. This updates that to update regardless of profile setting so bursts behave as expected on non-AGGRESSIVE modes.
232 lines
10 KiB
Python
232 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
|
|
|
|
log.debug("Issuing receipt in %.3f seconds", sleep_seconds)
|
|
await asyncio.sleep(sleep_seconds)
|
|
last_receipt_rooms[room_id] = time.time()
|
|
|
|
if self.profile().send_receipts:
|
|
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)
|
|
|
|
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)
|