def __init__(self, bridge: 'Bridge', homeserver_address: str, user_id_prefix: str, user_id_suffix: str, db_url: str, key_sharing_config: Dict[str, bool] = None ) -> None: self.loop = bridge.loop or asyncio.get_event_loop() self.bridge = bridge self.az = bridge.az self.device_name = bridge.name self._id_prefix = user_id_prefix self._id_suffix = user_id_suffix self._share_session_events = {} self.key_sharing_config = key_sharing_config or {} pickle_key = "mautrix.bridge.e2ee" if db_url.startswith("postgres://"): if not PgCryptoStore or not PgCryptoStateStore: raise RuntimeError("Database URL is set to postgres, but asyncpg is not installed") self.crypto_db = Database(url=db_url, upgrade_table=PgCryptoStore.upgrade_table, log=logging.getLogger("mau.crypto.db"), loop=self.loop) self.crypto_store = PgCryptoStore("", pickle_key, self.crypto_db) self.state_store = PgCryptoStateStore(self.crypto_db, bridge.get_portal) elif db_url.startswith("pickle:///"): self.crypto_db = None self.crypto_store = PickleCryptoStore("", pickle_key, db_url[len("pickle:///"):]) self.state_store = SQLCryptoStateStore(bridge.get_portal) else: raise RuntimeError("Unsupported database scheme") self.client = Client(base_url=homeserver_address, mxid=self.az.bot_mxid, loop=self.loop, sync_store=self.crypto_store) self.crypto = OlmMachine(self.client, self.crypto_store, self.state_store) self.crypto.allow_key_share = self.allow_key_share
async def login(evt: CommandEvent) -> EventID: override_sender = False if len(evt.args) > 0 and evt.sender.is_admin and evt.args[0]: override_user_id = UserID(evt.args[0]) try: Client.parse_user_id(override_user_id) except ValueError: return await evt.reply( f"**Usage:** `$cmdprefix+sp login [override user ID]`\n\n" f"{override_user_id!r} is not a valid Matrix user ID" ) orig_user_id = evt.sender.mxid evt.sender = await u.User.get_and_start_by_mxid(override_user_id) override_sender = True if orig_user_id != evt.sender: await evt.reply( f"Admin override: logging in as {evt.sender.mxid} instead of {orig_user_id}" ) if await evt.sender.is_logged_in(): return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.") allow_matrix_login = evt.config["bridge.allow_matrix_login"] if allow_matrix_login and not override_sender: evt.sender.command_status = { "next": enter_phone_or_token, "action": "Login", } nb = "**N.B. Logging in grants the bridge full access to your Telegram account.**" if evt.config["appservice.public.enabled"]: prefix = evt.config["appservice.public.external"] url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}" if override_sender: return await evt.reply( f"[Click here to log in]({url}) as " f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid})." ) elif allow_matrix_login: return await evt.reply( f"[Click here to log in]({url}). Alternatively, send your phone" f" number (or bot auth token) here to log in.\n\n{nb}" ) return await evt.reply(f"[Click here to log in]({url}).\n\n{nb}") elif allow_matrix_login: if override_sender: return await evt.reply( "This bridge instance does not allow you to log in outside of Matrix. " "Logging in as another user inside Matrix is not currently possible." ) return await evt.reply( "Please send your phone number (or bot auth token) here to start " f"the login process.\n\n{nb}" ) return await evt.reply("This bridge instance has been configured to not allow logging in.")
async def exchange_token(request: web.Request) -> web.Response: try: data: 'OpenIDPayload' = await request.json() except json.JSONDecodeError: raise Error.request_not_json if "access_token" not in data or "matrix_server_name" not in data: raise Error.invalid_openid_payload try: user_id = await check_openid_token(data["access_token"]) _, homeserver = Client.parse_user_id(user_id) except (ClientError, json.JSONDecodeError, KeyError, ValueError): raise Error.invalid_openid_token if homeserver != data["matrix_server_name"]: raise Error.homeserver_mismatch permissions = config.get_permissions(user_id) if not permissions.user: raise Error.no_access token = Token.random(user_id) await token.insert() return web.json_response( { "user_id": user_id, "token": token.secret, "level": permissions.level, "permissions": { "docker": permissions.admin, }, }, headers=account_cors_headers)
def get_permissions(self, mxid: UserID) -> Permissions: permissions = self["bridge.permissions"] if mxid in permissions: return self._get_permissions(mxid) _, homeserver = Client.parse_user_id(mxid) if homeserver in permissions: return self._get_permissions(homeserver) return self._get_permissions("*")
def __init__( self, bridge: br.Bridge, homeserver_address: str, user_id_prefix: str, user_id_suffix: str, db_url: str, key_sharing_config: dict[str, bool] = None, ) -> None: self.loop = bridge.loop or asyncio.get_event_loop() self.bridge = bridge self.az = bridge.az self.device_name = bridge.name self._id_prefix = user_id_prefix self._id_suffix = user_id_suffix self._share_session_events = {} self.key_sharing_config = key_sharing_config or {} pickle_key = "mautrix.bridge.e2ee" self.crypto_db = Database.create( url=db_url, upgrade_table=PgCryptoStore.upgrade_table, log=logging.getLogger("mau.crypto.db"), ) self.crypto_store = PgCryptoStore("", pickle_key, self.crypto_db) self.state_store = PgCryptoStateStore(self.crypto_db, bridge.get_portal) default_http_retry_count = bridge.config.get( "homeserver.http_retry_count", None) self.client = Client( base_url=homeserver_address, mxid=self.az.bot_mxid, loop=self.loop, sync_store=self.crypto_store, log=self.log.getChild("client"), default_retry_count=default_http_retry_count, ) self.crypto = OlmMachine(self.client, self.crypto_store, self.state_store) self.client.add_event_handler(InternalEventType.SYNC_STOPPED, self._exit_on_sync_fail) self.crypto.allow_key_share = self.allow_key_share
async def _create_community(self) -> None: template = self.config["bridge.community_template"] if not template: return localpart, server = MxClient.parse_user_id(self.mxid) community_localpart = template.format(localpart=localpart, server=server) self.log.debug(f"Creating personal filtering community {community_localpart}...") self._community_id, created = await self._community_helper.create(community_localpart) if created: await self._community_helper.update(self._community_id, name="Facebook Messenger", avatar_url=self.config["appservice.bot_avatar"], short_desc="Your Facebook bridged chats") await self._community_helper.invite(self._community_id, self.mxid)
def __init__(self, mconf: Optional[MirkConfig] = None): self._mconf = mconf if not self._mconf: self._mconf = MirkConfig() self._mconf.load() self._joined_rooms: list[str] = [] self._client = MatrixClient(mxid=self._mconf.username, base_url=self._mconf.host) self._client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite) self._client.add_event_handler(EventType.ROOM_MESSAGE, self._handle_message)
async def login_matrix(evt: CommandEvent) -> None: puppet = await pu.Puppet.get_by_address(evt.sender.address) _, homeserver = Client.parse_mxid(evt.sender.mxid) if homeserver != pu.Puppet.hs_domain: await evt.reply("You can't log in with an account on a different homeserver") return try: await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) await evt.reply("Successfully replaced your Signal account's " "Matrix puppet with your Matrix account.") except cpu.OnlyLoginSelf: await evt.reply("You may only log in with your own Matrix account") except cpu.InvalidAccessToken: await evt.reply("Invalid access token")
class MirkMatrixClient: ''' Handle interactions with Matrix for Laniakea's "Mirk" Matrix bot. ''' def __init__(self, mconf: Optional[MirkConfig] = None): self._mconf = mconf if not self._mconf: self._mconf = MirkConfig() self._mconf.load() self._joined_rooms: list[str] = [] self._client = MatrixClient(mxid=self._mconf.username, base_url=self._mconf.host) self._client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite) self._client.add_event_handler(EventType.ROOM_MESSAGE, self._handle_message) async def _handle_invite(self, event) -> None: if event.content.membership == Membership.INVITE: await self._client.join_room(event.room_id) async def _handle_message(self, event: MessageEvent) -> None: if event.sender != self._client.mxid: pass # we don't reply to anything for now async def login(self): ''' Log into Matrix as the currently selected bot user, using password authentication ''' log.debug('Logging into Matrix') await self._client.login(password=self._mconf.password) log.info('Logged into Matrix as %s', await self._client.whoami()) self._joined_rooms = await self._client.get_joined_rooms() log.info('Publishing in rooms: %s', ' '.join(self._joined_rooms)) def stop(self): self._client.stop() async def send_simple_text(self, room_id: RoomID, text: str): ''' Publish a simple text message in the selected room. ''' # pylint: disable=unexpected-keyword-arg await self._client.send_message(room_id=room_id, content=TextMessageEventContent( msgtype=MessageType.TEXT, body=text)) async def send_simple_html(self, room_id: RoomID, html: str): ''' Publish a simple HTML message in the selected room. ''' from mautrix.types import Format # pylint: disable=unexpected-keyword-arg content = TextMessageEventContent(msgtype=MessageType.TEXT) content.format = Format.HTML content.formatted_body = html await self._client.send_message(room_id=room_id, content=content)
class EncryptionManager: loop: asyncio.AbstractEventLoop log: TraceLogger = logging.getLogger("mau.bridge.e2ee") client: Client crypto: OlmMachine crypto_store: CryptoStore | SyncStore crypto_db: Database | None state_store: StateStore bridge: br.Bridge az: AppService _id_prefix: str _id_suffix: str _share_session_events: dict[RoomID, asyncio.Event] def __init__( self, bridge: br.Bridge, homeserver_address: str, user_id_prefix: str, user_id_suffix: str, db_url: str, key_sharing_config: dict[str, bool] = None, ) -> None: self.loop = bridge.loop or asyncio.get_event_loop() self.bridge = bridge self.az = bridge.az self.device_name = bridge.name self._id_prefix = user_id_prefix self._id_suffix = user_id_suffix self._share_session_events = {} self.key_sharing_config = key_sharing_config or {} pickle_key = "mautrix.bridge.e2ee" self.crypto_db = Database.create( url=db_url, upgrade_table=PgCryptoStore.upgrade_table, log=logging.getLogger("mau.crypto.db"), ) self.crypto_store = PgCryptoStore("", pickle_key, self.crypto_db) self.state_store = PgCryptoStateStore(self.crypto_db, bridge.get_portal) default_http_retry_count = bridge.config.get( "homeserver.http_retry_count", None) self.client = Client( base_url=homeserver_address, mxid=self.az.bot_mxid, loop=self.loop, sync_store=self.crypto_store, log=self.log.getChild("client"), default_retry_count=default_http_retry_count, ) self.crypto = OlmMachine(self.client, self.crypto_store, self.state_store) self.client.add_event_handler(InternalEventType.SYNC_STOPPED, self._exit_on_sync_fail) self.crypto.allow_key_share = self.allow_key_share async def _exit_on_sync_fail(self, data) -> None: if data["error"]: self.log.critical("Exiting due to crypto sync error") sys.exit(32) async def allow_key_share(self, device: DeviceIdentity, request: RequestedKeyInfo) -> bool: require_verification = self.key_sharing_config.get( "require_verification", True) allow = self.key_sharing_config.get("allow", False) if not allow: self.log.debug( f"Key sharing not enabled, ignoring key request from " f"{device.user_id}/{device.device_id}") return False elif device.trust == TrustState.BLACKLISTED: raise RejectKeyShare( f"Rejecting key request from blacklisted device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.BLACKLISTED, reason="You have been blacklisted by this device", ) elif device.trust == TrustState.VERIFIED or not require_verification: portal = await self.bridge.get_portal(request.room_id) if portal is None: raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: room is not a portal", code=RoomKeyWithheldCode.UNAVAILABLE, reason="Requested room is not a portal", ) user = await self.bridge.get_user(device.user_id) if not await user.is_in_portal(portal): raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: user is not in portal", code=RoomKeyWithheldCode.UNAUTHORIZED, reason="You're not in that portal", ) self.log.debug( f"Accepting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}") return True else: raise RejectKeyShare( f"Rejecting key request from unverified device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.UNVERIFIED, reason="You have not been verified by this device", ) def _ignore_user(self, user_id: str) -> bool: return (user_id.startswith(self._id_prefix) and user_id.endswith(self._id_suffix) and user_id != self.az.bot_mxid) async def handle_member_event(self, evt: StateEvent) -> None: if self._ignore_user(evt.state_key): # We don't want to invalidate group sessions because a ghost left or joined return await self.crypto.handle_member_event(evt) async def _share_session_lock(self, room_id: RoomID) -> bool: try: event = self._share_session_events[room_id] except KeyError: self._share_session_events[room_id] = asyncio.Event() return True else: await event.wait() return False async def encrypt( self, room_id: RoomID, event_type: EventType, content: Serializable | JSON ) -> tuple[EventType, EncryptedMegolmEventContent]: try: encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) except EncryptionError: self.log.debug( "Got EncryptionError, sharing group session and trying again") if await self._share_session_lock(room_id): try: users = await self.az.state_store.get_members_filtered( room_id, self._id_prefix, self._id_suffix, self.az.bot_mxid) await self.crypto.share_group_session(room_id, users) finally: self._share_session_events.pop(room_id).set() encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) return EventType.ROOM_ENCRYPTED, encrypted async def decrypt(self, evt: EncryptedEvent, wait_session_timeout: int = 5) -> MessageEvent: try: decrypted = await self.crypto.decrypt_megolm_event(evt) except SessionNotFound as e: if not wait_session_timeout: raise self.log.debug( f"Couldn't find session {e.session_id} trying to decrypt {evt.event_id}," f" waiting {wait_session_timeout} seconds...") got_keys = await self.crypto.wait_for_session( evt.room_id, e.sender_key, e.session_id, timeout=wait_session_timeout) if got_keys: self.log.debug(f"Got session {e.session_id} after waiting, " f"trying to decrypt {evt.event_id} again") decrypted = await self.crypto.decrypt_megolm_event(evt) else: raise self.log.trace("Decrypted event %s: %s", evt.event_id, decrypted) return decrypted async def start(self) -> None: flows = await self.client.get_login_flows() if not flows.supports_type(LoginType.APPSERVICE): self.log.critical( "Encryption enabled in config, but homeserver does not support appservice login" ) sys.exit(30) self.log.debug("Logging in with bridge bot user") if self.crypto_db: try: await self.crypto_db.start() except Exception as e: self.bridge._log_db_error(e) await self.crypto_store.open() device_id = await self.crypto_store.get_device_id() if device_id: self.log.debug(f"Found device ID in database: {device_id}") # We set the API token to the AS token here to authenticate the appservice login # It'll get overridden after the login self.client.api.token = self.az.as_token await self.client.login( login_type=LoginType.APPSERVICE, device_name=self.device_name, device_id=device_id, store_access_token=True, update_hs_url=False, ) await self.crypto.load() if not device_id: await self.crypto_store.put_device_id(self.client.device_id) self.log.debug( f"Logged in with new device ID {self.client.device_id}") _ = self.client.start(self._filter) self.log.info("End-to-bridge encryption support is enabled") async def stop(self) -> None: self.client.stop() await self.crypto_store.close() if self.crypto_db: await self.crypto_db.stop() @property def _filter(self) -> Filter: all_events = EventType.find("*") return Filter( account_data=EventFilter(types=[all_events]), presence=EventFilter(not_types=[all_events]), room=RoomFilter( include_leave=False, state=StateFilter(not_types=[all_events]), timeline=RoomEventFilter(not_types=[all_events]), account_data=RoomEventFilter(not_types=[all_events]), ephemeral=RoomEventFilter(not_types=[all_events]), ), )
def mxid_localpart(self) -> str: localpart, server = Client.parse_user_id(self.mxid) return localpart
class EncryptionManager: loop: asyncio.AbstractEventLoop log: TraceLogger = logging.getLogger("mau.bridge.e2ee") client: Client crypto: OlmMachine crypto_store: Union[CryptoStore, SyncStore] crypto_db: Optional[Database] state_store: StateStore bridge: 'Bridge' az: AppService login_shared_secret: bytes _id_prefix: str _id_suffix: str sync_task: asyncio.Future _share_session_events: Dict[RoomID, asyncio.Event] def __init__(self, bridge: 'Bridge', login_shared_secret: str, homeserver_address: str, user_id_prefix: str, user_id_suffix: str, db_url: str, key_sharing_config: Dict[str, bool] = None) -> None: self.loop = bridge.loop or asyncio.get_event_loop() self.bridge = bridge self.az = bridge.az self.device_name = bridge.name self._id_prefix = user_id_prefix self._id_suffix = user_id_suffix self.login_shared_secret = login_shared_secret.encode("utf-8") self._share_session_events = {} self.key_sharing_config = key_sharing_config or {} pickle_key = "mautrix.bridge.e2ee" if db_url.startswith("postgres://"): if not PgCryptoStore or not PgCryptoStateStore: raise RuntimeError( "Database URL is set to postgres, but asyncpg is not installed" ) self.crypto_db = Database( url=db_url, upgrade_table=PgCryptoStore.upgrade_table, log=logging.getLogger("mau.crypto.db"), loop=self.loop) self.crypto_store = PgCryptoStore("", pickle_key, self.crypto_db) self.state_store = PgCryptoStateStore(self.crypto_db, bridge.get_portal) elif db_url.startswith("pickle:///"): self.crypto_db = None self.crypto_store = PickleCryptoStore("", pickle_key, db_url[len("pickle:///"):]) self.state_store = SQLCryptoStateStore(bridge.get_portal) else: raise RuntimeError("Unsupported database scheme") self.client = Client(base_url=homeserver_address, mxid=self.az.bot_mxid, loop=self.loop, sync_store=self.crypto_store) self.crypto = OlmMachine(self.client, self.crypto_store, self.state_store) self.crypto.allow_key_share = self.allow_key_share async def allow_key_share(self, device: DeviceIdentity, request: RequestedKeyInfo) -> bool: require_verification = self.key_sharing_config.get( "require_verification", True) allow = self.key_sharing_config.get("allow", False) if not allow: self.log.debug( f"Key sharing not enabled, ignoring key request from " f"{device.user_id}/{device.device_id}") return False elif device.trust == TrustState.BLACKLISTED: raise RejectKeyShare( f"Rejecting key request from blacklisted device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.BLACKLISTED, reason="You have been blacklisted by this device") elif device.trust == TrustState.VERIFIED or not require_verification: portal = await self.bridge.get_portal(request.room_id) if portal is None: raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: room is not a portal", code=RoomKeyWithheldCode.UNAVAILABLE, reason="Requested room is not a portal") user = await self.bridge.get_user(device.user_id) if not await user.is_in_portal(portal): raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: user is not in portal", code=RoomKeyWithheldCode.UNAUTHORIZED, reason="You're not in that portal") self.log.debug( f"Accepting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}") return True else: raise RejectKeyShare( f"Rejecting key request from unverified device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.UNVERIFIED, reason="You have not been verified by this device") def _ignore_user(self, user_id: str) -> bool: return (user_id.startswith(self._id_prefix) and user_id.endswith(self._id_suffix) and user_id != self.az.bot_mxid) async def handle_member_event(self, evt: StateEvent) -> None: if self._ignore_user(evt.state_key): # We don't want to invalidate group sessions because a ghost left or joined return await self.crypto.handle_member_event(evt) async def _share_session_lock(self, room_id: RoomID) -> bool: try: event = self._share_session_events[room_id] except KeyError: self._share_session_events[room_id] = asyncio.Event() return True else: await event.wait() return False async def encrypt( self, room_id: RoomID, event_type: EventType, content: Union[Serializable, JSON] ) -> Tuple[EventType, EncryptedMegolmEventContent]: try: encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) except EncryptionError: self.log.debug( "Got EncryptionError, sharing group session and trying again") if await self._share_session_lock(room_id): try: users = await self.az.state_store.get_members_filtered( room_id, self._id_prefix, self._id_suffix, self.az.bot_mxid) await self.crypto.share_group_session(room_id, users) finally: self._share_session_events.pop(room_id).set() encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) return EventType.ROOM_ENCRYPTED, encrypted async def decrypt(self, evt: EncryptedEvent) -> MessageEvent: decrypted = await self.crypto.decrypt_megolm_event(evt) self.log.trace("Decrypted event %s: %s", evt.event_id, decrypted) return decrypted async def start(self) -> None: self.log.debug("Logging in with bridge bot user") password = hmac.new(self.login_shared_secret, self.az.bot_mxid.encode("utf-8"), hashlib.sha512).hexdigest() if self.crypto_db: await self.crypto_db.start() await self.crypto_store.open() device_id = await self.crypto_store.get_device_id() if device_id: self.log.debug(f"Found device ID in database: {device_id}") await self.client.login(password=password, device_name=self.device_name, device_id=device_id, store_access_token=True, update_hs_url=False) await self.crypto.load() if not device_id: await self.crypto_store.put_device_id(self.client.device_id) self.log.debug( f"Logged in with new device ID {self.client.device_id}") self.sync_task = self.client.start(self._filter) self.log.info("End-to-bridge encryption support is enabled") async def stop(self) -> None: self.sync_task.cancel() await self.crypto_store.close() if self.crypto_db: await self.crypto_db.stop() @property def _filter(self) -> Filter: all_events = EventType.find("*") return Filter( account_data=EventFilter(types=[all_events]), presence=EventFilter(not_types=[all_events]), room=RoomFilter( include_leave=False, state=StateFilter(not_types=[all_events]), timeline=RoomEventFilter(not_types=[all_events]), account_data=RoomEventFilter(not_types=[all_events]), ephemeral=RoomEventFilter(not_types=[all_events]), ), )
class KarmaBot(Plugin): karma_t: Type[Karma] version: Type[Version] async def start(self) -> None: await super().start() self.config.load_and_update() self.karma_t, self.version = make_tables(self.database) @command.new("karma", help="View users' karma or karma top lists") async def karma(self) -> None: pass @karma.subcommand("up", help="Upvote an event") @command.argument("event_id", "Event ID", required=True) def upvote(self, evt: MessageEvent, event_id: EventID) -> Awaitable[None]: return self._vote(evt, event_id, +1) @karma.subcommand("down", help="Downvote a message") @command.argument("event_id", "Event ID", required=True) def downvote(self, evt: MessageEvent, event_id: EventID) -> Awaitable[None]: return self._vote(evt, event_id, -1) @command.passive(UPVOTE) def upvote(self, evt: MessageEvent, _: Tuple[str]) -> Awaitable[None]: return self._vote(evt, evt.content.get_reply_to(), +1) @command.passive(DOWNVOTE) def downvote(self, evt: MessageEvent, _: Tuple[str]) -> Awaitable[None]: return self._vote(evt, evt.content.get_reply_to(), -1) @command.passive(regex=UPVOTE_EMOJI, field=lambda evt: evt.content.relates_to.key, event_type=EventType.REACTION, msgtypes=None) def upvote_react(self, evt: ReactionEvent, key: Tuple[str]) -> Awaitable[None]: try: return self._vote(evt, evt.content.relates_to.event_id, 1) except KeyError: pass @command.passive(regex=DOWNVOTE_EMOJI, field=lambda evt: evt.content.relates_to.key, event_type=EventType.REACTION, msgtypes=None) def downvote_react(self, evt: ReactionEvent, key: Tuple[str]) -> Awaitable[None]: try: return self._vote(evt, evt.content.relates_to.event_id, -1) except KeyError: pass @event.on(EventType.ROOM_REDACTION) async def redact(self, evt: RedactionEvent) -> None: karma = self.karma_t.get_by_given_from(evt.redacts) if karma: self.log.debug( f"Deleting {karma} due to redaction by {evt.sender}.") karma.delete() @karma.subcommand("stats", help="View global karma statistics") async def karma_stats(self, evt: MessageEvent) -> None: await evt.reply("Not yet implemented :(") @karma.subcommand("view", help="View your or another users karma") @command.argument("user", "user ID", required=False, parser=lambda val: Client.parse_user_id(val) if val else None) async def view_karma(self, evt: MessageEvent, user: Optional[Tuple[str, str]]) -> None: if user is not None: mxid = UserID(f"@{user[0]}:{user[1]}") name = f"[{user[0]}](https://matrix.to/#/{mxid})" word_have = "has" word_to_be = "is" else: mxid = evt.sender name = "You" word_have = "have" word_to_be = "are" karma = self.karma_t.get_karma(mxid) if karma is None or karma.total is None: await evt.reply(f"{name} {word_have} no karma :(") return index = self.karma_t.find_index_from_top(mxid) await evt.reply( f"{name} {word_have} {karma.total} karma " f"(+{karma.positive}/-{karma.negative}) " f"and {word_to_be} #{index + 1 or '∞'} on the top list.") @karma.subcommand("export", help="Export the data of your karma") async def export_own_karma(self, evt: MessageEvent) -> None: karma_list = [ karma.to_dict() for karma in self.karma_t.export(evt.sender) ] data = json.dumps(karma_list).encode("utf-8") url = await self.client.upload_media(data, mime_type="application/json") await evt.reply( MediaMessageEventContent(msgtype=MessageType.FILE, body=f"karma-{evt.sender}.json", url=url, info=FileInfo( mimetype="application/json", size=len(data), ))) @karma.subcommand("breakdown", help="View your karma breakdown") async def own_karma_breakdown(self, evt: MessageEvent) -> None: await evt.reply("Not yet implemented :(") @karma.subcommand("top", help="View the highest rated users") async def karma_top(self, evt: MessageEvent) -> None: await evt.reply(self._karma_user_list("top")) @karma.subcommand("bottom", help="View the lowest rated users") async def karma_bottom(self, evt: MessageEvent) -> None: await evt.reply(self._karma_user_list("bottom")) @karma.subcommand("best", help="View the highest rated messages") async def karma_best(self, evt: MessageEvent) -> None: await evt.reply(self._karma_message_list("best")) @karma.subcommand("worst", help="View the lowest rated messages") async def karma_worst(self, evt: MessageEvent) -> None: await evt.reply(self._karma_message_list("worst")) def _parse_content(self, evt: Event) -> str: if not self.config["store_content"]: return "" if isinstance(evt, MessageEvent): if evt.content.msgtype in (MessageType.NOTICE, MessageType.TEXT, MessageType.EMOTE): body = evt.content.body if evt.content.msgtype == MessageType.EMOTE: body = "/me " + body if self.config["store_content"] == "partial": body = body.split("\n")[0] if len(body) > 60: body = body[:50] + " \u2026" return body name = media_reply_fallback_body_map[evt.content.msgtype] return f"[{name}]({self.client.api.get_download_url(evt.content.url)})" elif isinstance(evt, StateEvent): return "a state event" return "an unknown event" @staticmethod def _sign(value: int) -> str: if value > 0: return f"+{value}" elif value < 0: return str(value) else: return "±0" async def _vote(self, evt: MessageEvent, target: EventID, value: int) -> None: if not target: return in_filter = evt.sender in self.config["filter"] if self.config["democracy"] == in_filter or sha1( evt.sender) in self.config["opt_out"]: if self.config["errors.filtered_users"] and isinstance( evt, MessageEvent): await evt.reply("Sorry, you're not allowed to vote.") return if self.karma_t.is_vote_event(target): if self.config["errors.vote_on_vote"] and isinstance( evt, MessageEvent): await evt.reply("Sorry, you can't vote on votes.") return karma_target = await self.client.get_event(evt.room_id, target) if not karma_target: return if karma_target.sender == evt.sender and value > 0: if self.config["errors.upvote_self"] and isinstance( evt, MessageEvent): await evt.reply("Hey! You can't upvote yourself!") return karma_id = dict(given_to=karma_target.sender, given_by=evt.sender, given_in=evt.room_id, given_for=karma_target.event_id) anonymize = sha1(karma_target.sender) in self.config["opt_out"] if anonymize: karma_id["given_to"] = "" existing = self.karma_t.get(**karma_id) if existing is not None: if existing.value == value: if self.config["errors.already_voted"] and isinstance( evt, MessageEvent): await evt.reply( f"You already {self._sign(value)}'d that message.") return existing.update(new_value=value) else: karma = self.karma_t(**karma_id, given_from=evt.event_id, value=value, content=self._parse_content(karma_target) if not anonymize else "") karma.insert() if isinstance(evt, MessageEvent): await evt.mark_read() def _denotify(self, mxid: UserID) -> str: localpart, _ = self.client.parse_user_id(mxid) return "\u2063".join(localpart) def _user_link(self, user_id: UserID) -> str: if not user_id: return "Anonymous" return f"[{self._denotify(user_id)}](https://matrix.to/#/{user_id})" def _karma_user_list(self, list_type: str) -> Optional[str]: if list_type == "top": karma_list = self.karma_t.get_top_users() message = "#### Highest karma\n\n" elif list_type in ("bot", "bottom"): karma_list = self.karma_t.get_bottom_users() message = "#### Lowest karma\n\n" else: return None message += "\n".join( f"{index + 1}. {self._user_link(karma.user_id)}: " f"{self._sign(karma.total)} (+{karma.positive}/-{karma.negative})" for index, karma in enumerate(karma_list) if karma.user_id) return message def _message_text(self, index, event) -> str: text = ( f"{index + 1}. [Event](https://matrix.to/#/{event.room_id}/{event.event_id})" f" by {self._user_link(event.sender)} with" f" {self._sign(event.total)} karma (+{event.positive}/-{event.negative})\n" ) if event.content and self.config["show_content"]: text += f" \n > {html.escape(event.content)}\n" return text def _karma_message_list(self, list_type: str) -> Optional[str]: if list_type == "best": karma_list = self.karma_t.get_best_events() message = "#### Best messages\n\n" elif list_type == "worst": karma_list = self.karma_t.get_worst_events() message = "#### Worst messages\n\n" else: return None message += "\n".join( self._message_text(index, event) for index, event in enumerate(karma_list)) return message @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: return Config
def _validate_user(user: Any) -> bool: try: Client.parse_user_id(user) return True except (ValueError, IndexError): return False