Compare commits
4 Commits
4158788496
...
9c330350e0
Author | SHA1 | Date | |
---|---|---|---|
9c330350e0 | |||
491cdca7b6 | |||
c7df4b1e65 | |||
1a947a1999 |
14
README.md
Normal file
14
README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# matrix-appservice-kakaotalk
|
||||||
|
A Matrix-KakaoTalk puppeting bridge.
|
||||||
|
Uses [node-kakao](https://github.com/storycraft/node-kakao) to communicate with the KakaoTalk API.
|
||||||
|
Bridge code based on [mautrix-facebook](https://github.com/mautrix/facebook).
|
||||||
|
|
||||||
|
## Features & roadmap
|
||||||
|
[ROADMAP.md](ROADMAP.md)
|
||||||
|
contains a general overview of what is supported by the bridge.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
[SETUP.md](SETUP.md)
|
||||||
|
|
||||||
|
## Discussion
|
||||||
|
Matrix room: [`#matrix-appservice-kakaotalk:miscworks.net`](https://matrix.to/#/#matrix-appservice-kakaotalk:miscworks.net)
|
67
ROADMAP.md
Normal file
67
ROADMAP.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# Features & roadmap
|
||||||
|
|
||||||
|
* Matrix → KakaoTalk
|
||||||
|
* [ ] Message content
|
||||||
|
* [x] Text
|
||||||
|
* [ ] Media
|
||||||
|
* [ ] Stickers
|
||||||
|
* [ ] Files
|
||||||
|
* [ ] Voice messages
|
||||||
|
* [ ] Videos
|
||||||
|
* [ ] Images
|
||||||
|
* [ ] Locations
|
||||||
|
* [ ] Formatting
|
||||||
|
* [ ] Replies
|
||||||
|
* [ ] Mentions
|
||||||
|
* [ ] Message redactions
|
||||||
|
* [ ] Message reactions
|
||||||
|
* [ ] Presence
|
||||||
|
* [ ] Typing notifications
|
||||||
|
* [ ] Read receipts
|
||||||
|
* [ ] Power level
|
||||||
|
* [ ] Membership actions
|
||||||
|
* [ ] Invite
|
||||||
|
* [ ] Kick
|
||||||
|
* [ ] Leave
|
||||||
|
* [ ] Room metadata changes
|
||||||
|
* [ ] Name
|
||||||
|
* [ ] Avatar
|
||||||
|
* [ ] Per-room user nick
|
||||||
|
* KakaoTalk → Matrix
|
||||||
|
* [ ] Message content
|
||||||
|
* [x] Text
|
||||||
|
* [ ] Media
|
||||||
|
* [ ] Stickers
|
||||||
|
* [ ] Videos
|
||||||
|
* [ ] Images
|
||||||
|
* [ ] Formatting
|
||||||
|
* [ ] Replies
|
||||||
|
* [ ] Mentions
|
||||||
|
* [ ] Message reactions
|
||||||
|
* [x] Message history
|
||||||
|
* [ ] Presence
|
||||||
|
* [ ] Typing notifications
|
||||||
|
* [ ] Read receipts
|
||||||
|
* [ ] Admin status
|
||||||
|
* [ ] Membership actions
|
||||||
|
* [ ] Add member
|
||||||
|
* [ ] Remove member
|
||||||
|
* [ ] Leave
|
||||||
|
* [ ] Chat metadata changes
|
||||||
|
* [x] Title
|
||||||
|
* [ ] Avatar
|
||||||
|
* [ ] Initial chat metadata
|
||||||
|
* [x] Title
|
||||||
|
* [ ] Avatar
|
||||||
|
* [x] User metadata
|
||||||
|
* [x] Name
|
||||||
|
* [x] Avatar
|
||||||
|
* Misc
|
||||||
|
* [x] Multi-user support
|
||||||
|
* [x] Shared group chat portals
|
||||||
|
* [ ] Automatic portal creation
|
||||||
|
* [x] At startup
|
||||||
|
* [ ] When added to chat
|
||||||
|
* [x] When receiving message
|
||||||
|
* [x] Private chat creation by inviting Matrix puppet of Messenger user to new room
|
||||||
|
* [x] Option to use own Matrix account for messages sent from other Messenger clients
|
49
SETUP.md
Normal file
49
SETUP.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
* [Manual setup](#manual-setup)
|
||||||
|
* [systemd](#systemd)
|
||||||
|
* [Docker](#docker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Manual setup
|
||||||
|
These instructions describe how to install and run the bridge manually from a clone of this repository.
|
||||||
|
|
||||||
|
## Minimum requirements
|
||||||
|
* Python 3.7
|
||||||
|
* Node 16.13 (not yet tested with earlier versions)
|
||||||
|
* postgresql 11
|
||||||
|
* A KakaoTalk account on a smartphone (Android or iOS)
|
||||||
|
|
||||||
|
## Optional requirements
|
||||||
|
* Native dependencies for [end-to-bridge](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html): https://docs.mau.fi/bridges/python/optional-dependencies.html#all-python-bridges
|
||||||
|
|
||||||
|
## Initial setup
|
||||||
|
|
||||||
|
### node-kakao module
|
||||||
|
1. `cd` to the `node` directory and run `npm install`
|
||||||
|
1. Copy `node/example-config.json` to `node/config.json`
|
||||||
|
1. Edit `node/config.json` with desired settings (see [node/README.md](node/README.md) for details)
|
||||||
|
|
||||||
|
### Bridge module
|
||||||
|
1. `cd` to the repository root and create a Python virtual environment with `python3 -m venv .venv`, and enter it with `source .venv/bin/activate`
|
||||||
|
1. Install Python requirements:
|
||||||
|
* `pip install -Ur requirements.txt` for base functionality
|
||||||
|
* `pip install -Ur optional-requirements.txt` for [end-to-bridge](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html) encryption and metrics
|
||||||
|
* Note that end-to-bridge encryption requires some native dependencies. For details, see https://docs.mau.fi/bridges/python/optional-dependencies.html#all-python-bridges
|
||||||
|
1. Copy `matrix_appservice_kakaotalk/example-config.yaml` to `config.yaml`, and update it with the proper settings to connect to your homeserver
|
||||||
|
* In particular, be sure to set the `rpc.connection` settings to use the socket you chose in `node/config.json`
|
||||||
|
1. Run `python -m matrix_appservice_kakaotalk -g` to generate an appservice registration file, and update your homeserver configuration to accept it
|
||||||
|
|
||||||
|
## Running manually
|
||||||
|
1. In the `node` directory, launch the node-kakao module with `node src/main.js`
|
||||||
|
1. In the project root directory, run the bridge module with `python -m matrix_appservice_kakaotalk`
|
||||||
|
1. Start a chat with the bot and use the `login <email>` command to sync your KakaoTalk account
|
||||||
|
* Note that on first use, you must enter a verification code on a smartphone version of KakaoTalk in order for the login to complete
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
Coming soon!
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
Simply `git pull` or `git rebase` the latest changes and rerun any installation commands (`npm install`, `pip install -Ur ...`).
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Coming soon!
|
@ -215,13 +215,14 @@ class Client:
|
|||||||
channel_id=channel_id.serialize()
|
channel_id=channel_id.serialize()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_chats(self, channel_id: Long, limit: int | None, sync_from: Long | None) -> list[Chatlog]:
|
async def get_chats(self, channel_id: Long, sync_from: Long | None, limit: int | None) -> list[Chatlog]:
|
||||||
return (await self._api_user_request_result(
|
return await self._api_user_request_result(
|
||||||
ResultListType(Chatlog),
|
ResultListType(Chatlog),
|
||||||
"get_chats",
|
"get_chats",
|
||||||
channel_id=channel_id.serialize(),
|
channel_id=channel_id.serialize(),
|
||||||
sync_from=sync_from.serialize() if sync_from else None
|
sync_from=sync_from.serialize() if sync_from else None,
|
||||||
))[-limit if limit else 0:]
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
async def send_message(self, channel_id: Long, text: str) -> Chatlog:
|
async def send_message(self, channel_id: Long, text: str) -> Chatlog:
|
||||||
return await self._api_user_request_result(
|
return await self._api_user_request_result(
|
||||||
|
@ -28,7 +28,7 @@ class KnownChannelType(str, Enum):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_direct(cls, value: Union["KnownChannelType", str]) -> bool:
|
def is_direct(cls, value: Union["KnownChannelType", str]) -> bool:
|
||||||
return value == KnownChannelType.DirectChat
|
return value in [cls.DirectChat, cls.MemoChat]
|
||||||
|
|
||||||
|
|
||||||
ChannelType = Union[KnownChannelType, str] # Substitute for ChannelType = "name1" | ... | "nameN" | str
|
ChannelType = Union[KnownChannelType, str] # Substitute for ChannelType = "name1" | ... | "nameN" | str
|
||||||
|
@ -92,6 +92,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
config: Config
|
config: Config
|
||||||
|
|
||||||
_main_intent: IntentAPI | None
|
_main_intent: IntentAPI | None
|
||||||
|
_kt_sender: int | None
|
||||||
_create_room_lock: asyncio.Lock
|
_create_room_lock: asyncio.Lock
|
||||||
_send_locks: dict[int, asyncio.Lock]
|
_send_locks: dict[int, asyncio.Lock]
|
||||||
_noop_lock: FakeLock = FakeLock()
|
_noop_lock: FakeLock = FakeLock()
|
||||||
@ -132,6 +133,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.log = self.log.getChild(self.ktid_log)
|
self.log = self.log.getChild(self.ktid_log)
|
||||||
|
|
||||||
self._main_intent = None
|
self._main_intent = None
|
||||||
|
self._kt_sender = None
|
||||||
self._create_room_lock = asyncio.Lock()
|
self._create_room_lock = asyncio.Lock()
|
||||||
self._send_locks = {}
|
self._send_locks = {}
|
||||||
self._typing = set()
|
self._typing = set()
|
||||||
@ -176,17 +178,31 @@ class Portal(DBPortal, BasePortal):
|
|||||||
@property
|
@property
|
||||||
def ktid_log(self) -> str:
|
def ktid_log(self) -> str:
|
||||||
if self.is_direct:
|
if self.is_direct:
|
||||||
return f"{self.ktid}<->{self.kt_receiver}"
|
return f"{self.ktid}->{self.kt_receiver}"
|
||||||
return str(self.ktid)
|
return str(self.ktid)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_direct(self) -> bool:
|
def is_direct(self) -> bool:
|
||||||
return KnownChannelType.is_direct(self.kt_type)
|
return KnownChannelType.is_direct(self.kt_type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kt_sender(self) -> int | None:
|
||||||
|
if self.is_direct:
|
||||||
|
if not self._kt_sender:
|
||||||
|
raise ValueError("Direct chat portal must set sender")
|
||||||
|
else:
|
||||||
|
if self._kt_sender:
|
||||||
|
raise ValueError(f"Non-direct chat portal should have no sender, but has sender {self._kt_sender}")
|
||||||
|
return self._kt_sender
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def main_intent(self) -> IntentAPI:
|
def main_intent(self) -> IntentAPI:
|
||||||
if not self._main_intent:
|
if not self._main_intent:
|
||||||
raise ValueError("Portal must be postinit()ed before main_intent can be used")
|
raise ValueError(
|
||||||
|
"Portal must be postinit()ed before main_intent can be used"
|
||||||
|
if not self.is_direct else
|
||||||
|
"Direct chat portal must call postinit and _update_participants before main_intent can be used"
|
||||||
|
)
|
||||||
return self._main_intent
|
return self._main_intent
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
@ -227,7 +243,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
source: u.User,
|
source: u.User,
|
||||||
info: PortalChannelInfo | None = None,
|
info: PortalChannelInfo | None = None,
|
||||||
force_save: bool = False,
|
force_save: bool = False,
|
||||||
) -> PortalChannelInfo | None:
|
) -> PortalChannelInfo:
|
||||||
if not info:
|
if not info:
|
||||||
self.log.debug("Called update_info with no info, fetching channel info...")
|
self.log.debug("Called update_info with no info, fetching channel info...")
|
||||||
info = await source.client.get_portal_channel_info(self.ktid)
|
info = await source.client.get_portal_channel_info(self.ktid)
|
||||||
@ -384,11 +400,19 @@ class Portal(DBPortal, BasePortal):
|
|||||||
|
|
||||||
async def _update_participants(self, source: u.User, participants: list[UserInfoUnion]) -> bool:
|
async def _update_participants(self, source: u.User, participants: list[UserInfoUnion]) -> bool:
|
||||||
changed = False
|
changed = False
|
||||||
|
if not self._main_intent:
|
||||||
|
assert self.is_direct, "_main_intent for non-direct chat portal should have been set already"
|
||||||
|
self._kt_sender = participants[
|
||||||
|
0 if self.kt_type == KnownChannelType.MemoChat or participants[0].userId != source.ktid else 1
|
||||||
|
].userId
|
||||||
|
self._main_intent = (await p.Puppet.get_by_ktid(self._kt_sender)).default_mxid_intent
|
||||||
|
else:
|
||||||
|
self._kt_sender = (await p.Puppet.get_by_mxid(self._main_intent.mxid)).ktid if self.is_direct else None
|
||||||
# TODO nick_map?
|
# TODO nick_map?
|
||||||
for participant in participants:
|
for participant in participants:
|
||||||
puppet = await p.Puppet.get_by_ktid(participant.userId)
|
puppet = await p.Puppet.get_by_ktid(participant.userId)
|
||||||
await puppet.update_info(source, participant)
|
await puppet.update_info(source, participant)
|
||||||
if self.is_direct and self.ktid == puppet.ktid and self.encrypted:
|
if self.is_direct and self._kt_sender == puppet.ktid and self.encrypted:
|
||||||
changed = await self._update_name(puppet.name) or changed
|
changed = await self._update_name(puppet.name) or changed
|
||||||
changed = await self._update_photo_from_puppet(puppet) or changed
|
changed = await self._update_photo_from_puppet(puppet) or changed
|
||||||
if self.mxid:
|
if self.mxid:
|
||||||
@ -418,6 +442,8 @@ class Portal(DBPortal, BasePortal):
|
|||||||
async def _update_matrix_room(
|
async def _update_matrix_room(
|
||||||
self, source: u.User, info: PortalChannelInfo | None = None
|
self, source: u.User, info: PortalChannelInfo | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
info = await self.update_info(source, info)
|
||||||
|
|
||||||
puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
|
puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
|
||||||
await self.main_intent.invite_user(
|
await self.main_intent.invite_user(
|
||||||
self.mxid,
|
self.mxid,
|
||||||
@ -430,11 +456,6 @@ class Portal(DBPortal, BasePortal):
|
|||||||
if did_join and self.is_direct:
|
if did_join and self.is_direct:
|
||||||
await source.update_direct_chats({self.main_intent.mxid: [self.mxid]})
|
await source.update_direct_chats({self.main_intent.mxid: [self.mxid]})
|
||||||
|
|
||||||
info = await self.update_info(source, info)
|
|
||||||
if not info:
|
|
||||||
self.log.warning("Canceling _update_matrix_room as update_info didn't return info")
|
|
||||||
return
|
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
#await self._sync_read_receipts(info.read_receipts.nodes)
|
#await self._sync_read_receipts(info.read_receipts.nodes)
|
||||||
|
|
||||||
@ -520,6 +541,9 @@ class Portal(DBPortal, BasePortal):
|
|||||||
return self.mxid
|
return self.mxid
|
||||||
|
|
||||||
self.log.debug(f"Creating Matrix room")
|
self.log.debug(f"Creating Matrix room")
|
||||||
|
if self.is_direct:
|
||||||
|
# NOTE Must do this to find the other member of the DM, since the channel ID != the member's ID!
|
||||||
|
await self._update_participants(source, info.participants)
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
initial_state = [
|
initial_state = [
|
||||||
{
|
{
|
||||||
@ -547,9 +571,6 @@ class Portal(DBPortal, BasePortal):
|
|||||||
invites.append(self.az.bot_mxid)
|
invites.append(self.az.bot_mxid)
|
||||||
|
|
||||||
info = await self.update_info(source=source, info=info)
|
info = await self.update_info(source=source, info=info)
|
||||||
if not info:
|
|
||||||
self.log.debug("update_info() didn't return info, cancelling room creation")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self.encrypted or not self.is_direct:
|
if self.encrypted or not self.is_direct:
|
||||||
name = self.name
|
name = self.name
|
||||||
@ -602,6 +623,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not self.is_direct:
|
if not self.is_direct:
|
||||||
|
# NOTE Calling this after room creation to invite participants
|
||||||
await self._update_participants(source, info.participants)
|
await self._update_participants(source, info.participants)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -941,8 +963,8 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.log.debug(f"Fetching {f'up to {limit}' if limit else 'all'} messages through {source.ktid}")
|
self.log.debug(f"Fetching {f'up to {limit}' if limit else 'all'} messages through {source.ktid}")
|
||||||
messages = await source.client.get_chats(
|
messages = await source.client.get_chats(
|
||||||
channel_info.channelId,
|
channel_info.channelId,
|
||||||
limit,
|
after_log_id,
|
||||||
after_log_id
|
limit
|
||||||
)
|
)
|
||||||
if not messages:
|
if not messages:
|
||||||
self.log.debug("Didn't get any messages from server")
|
self.log.debug("Didn't get any messages from server")
|
||||||
@ -964,11 +986,10 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.by_ktid[self._ktid_full] = self
|
self.by_ktid[self._ktid_full] = self
|
||||||
if self.mxid:
|
if self.mxid:
|
||||||
self.by_mxid[self.mxid] = self
|
self.by_mxid[self.mxid] = self
|
||||||
self._main_intent = (
|
if not self.is_direct:
|
||||||
(await p.Puppet.get_by_ktid(self.ktid)).default_mxid_intent
|
self._main_intent = self.az.intent
|
||||||
if self.is_direct
|
else:
|
||||||
else self.az.intent
|
self.log.debug("Not setting _main_intent of direct chat until after checking participant list")
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_getter_lock
|
@async_getter_lock
|
||||||
@ -995,6 +1016,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
create: bool = True,
|
create: bool = True,
|
||||||
kt_type: ChannelType | None = None,
|
kt_type: ChannelType | None = None,
|
||||||
) -> Portal | None:
|
) -> Portal | None:
|
||||||
|
# TODO Find out if direct channels are shared. If so, don't need kt_receiver!
|
||||||
if kt_type:
|
if kt_type:
|
||||||
kt_receiver = kt_receiver if KnownChannelType.is_direct(kt_type) else 0
|
kt_receiver = kt_receiver if KnownChannelType.is_direct(kt_type) else 0
|
||||||
ktid_full = (ktid, kt_receiver)
|
ktid_full = (ktid, kt_receiver)
|
||||||
|
@ -33,6 +33,7 @@ from .db import Puppet as DBPuppet
|
|||||||
|
|
||||||
from .kt.types.bson import Long
|
from .kt.types.bson import Long
|
||||||
|
|
||||||
|
from .kt.types.channel.channel_type import KnownChannelType
|
||||||
from .kt.client.types import UserInfoUnion
|
from .kt.client.types import UserInfoUnion
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -94,8 +95,9 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
|
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
|
||||||
|
# TODO Find out if direct channels are shared. If not, default puppet shouldn't leave!
|
||||||
portal = await p.Portal.get_by_mxid(room_id)
|
portal = await p.Portal.get_by_mxid(room_id)
|
||||||
return portal and portal.ktid != self.ktid
|
return portal and portal.kt_type != KnownChannelType.MemoChat
|
||||||
|
|
||||||
async def _leave_rooms_with_default_user(self) -> None:
|
async def _leave_rooms_with_default_user(self) -> None:
|
||||||
await super()._leave_rooms_with_default_user()
|
await super()._leave_rooms_with_default_user()
|
||||||
|
@ -460,7 +460,7 @@ class User(DBUser, BaseUser):
|
|||||||
|
|
||||||
sync_count = min(sync_count, len(login_result.channelList))
|
sync_count = min(sync_count, len(login_result.channelList))
|
||||||
await self.push_bridge_state(BridgeStateEvent.BACKFILLING)
|
await self.push_bridge_state(BridgeStateEvent.BACKFILLING)
|
||||||
self.log.debug(f"Syncing {sync_count} of {login_result.channelList} channels...")
|
self.log.debug(f"Syncing {sync_count} of {len(login_result.channelList)} channels...")
|
||||||
for channel_item in login_result.channelList[:sync_count]:
|
for channel_item in login_result.channelList[:sync_count]:
|
||||||
# TODO try-except here, above, below?
|
# TODO try-except here, above, below?
|
||||||
await self._sync_channel(channel_item)
|
await self._sync_channel(channel_item)
|
||||||
|
4
node/README.md
Normal file
4
node/README.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
### Listen config
|
||||||
|
If `type` is `unix`, `path` is the path where to create the socket.
|
||||||
|
|
||||||
|
If `type` is `tcp`, `port` and `host` are the host/port where to listen.
|
@ -339,12 +339,17 @@ export default class PeerClient {
|
|||||||
* @param {string} req.mxid
|
* @param {string} req.mxid
|
||||||
* @param {Long} req.channel_id
|
* @param {Long} req.channel_id
|
||||||
* @param {Long?} req.sync_from
|
* @param {Long?} req.sync_from
|
||||||
|
* @param {Number?} req.limit
|
||||||
*/
|
*/
|
||||||
getChats = async (req) => {
|
getChats = async (req) => {
|
||||||
const userClient = this.#getUser(req.mxid)
|
const userClient = this.#getUser(req.mxid)
|
||||||
const talkChannel = userClient.talkClient.channelList.get(req.channel_id)
|
const talkChannel = userClient.talkClient.channelList.get(req.channel_id)
|
||||||
|
|
||||||
return await talkChannel.getChatListFrom(req.sync_from)
|
const res = await talkChannel.getChatListFrom(req.sync_from)
|
||||||
|
if (res.success && 0 < req.limit && req.limit < res.result.length) {
|
||||||
|
res.result.splice(0, res.result.length - req.limit)
|
||||||
|
}
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user