puppet.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. # mautrix-signal - A Matrix-Signal puppeting bridge
  2. # Copyright (C) 2020 Tulir Asokan
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU Affero General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. from typing import (Optional, Dict, AsyncIterable, Awaitable, AsyncGenerator, Union,
  17. TYPE_CHECKING, cast)
  18. from uuid import UUID
  19. import asyncio
  20. from yarl import URL
  21. from mausignald.types import Address, Contact, Profile
  22. from mautrix.bridge import BasePuppet
  23. from mautrix.appservice import IntentAPI
  24. from mautrix.types import UserID, SyncToken, RoomID
  25. from mautrix.errors import MForbidden
  26. from mautrix.util.simple_template import SimpleTemplate
  27. from .db import Puppet as DBPuppet
  28. from .config import Config
  29. from . import portal as p, user as u
  30. if TYPE_CHECKING:
  31. from .__main__ import SignalBridge
  32. try:
  33. import phonenumbers
  34. except ImportError:
  35. phonenumbers = None
  36. class Puppet(DBPuppet, BasePuppet):
  37. by_uuid: Dict[UUID, 'Puppet'] = {}
  38. by_number: Dict[str, 'Puppet'] = {}
  39. by_custom_mxid: Dict[UserID, 'Puppet'] = {}
  40. hs_domain: str
  41. mxid_template: SimpleTemplate[str]
  42. config: Config
  43. default_mxid_intent: IntentAPI
  44. default_mxid: UserID
  45. _uuid_lock: asyncio.Lock
  46. _update_info_lock: asyncio.Lock
  47. def __init__(self, uuid: Optional[UUID], number: Optional[str], name: Optional[str] = None,
  48. uuid_registered: bool = False, number_registered: bool = False,
  49. custom_mxid: Optional[UserID] = None, access_token: Optional[str] = None,
  50. next_batch: Optional[SyncToken] = None, base_url: Optional[URL] = None) -> None:
  51. super().__init__(uuid=uuid, number=number, name=name, uuid_registered=uuid_registered,
  52. number_registered=number_registered, custom_mxid=custom_mxid,
  53. access_token=access_token, next_batch=next_batch, base_url=base_url)
  54. self.log = self.log.getChild(str(uuid) if uuid else number)
  55. self.default_mxid = self.get_mxid_from_id(self.address)
  56. self.default_mxid_intent = self.az.intent.user(self.default_mxid)
  57. self.intent = self._fresh_intent()
  58. self._uuid_lock = asyncio.Lock()
  59. self._update_info_lock = asyncio.Lock()
  60. @classmethod
  61. def init_cls(cls, bridge: 'SignalBridge') -> AsyncIterable[Awaitable[None]]:
  62. cls.config = bridge.config
  63. cls.loop = bridge.loop
  64. cls.mx = bridge.matrix
  65. cls.az = bridge.az
  66. cls.hs_domain = cls.config["homeserver.domain"]
  67. cls.mxid_template = SimpleTemplate(cls.config["bridge.username_template"], "userid",
  68. prefix="@", suffix=f":{cls.hs_domain}", type=str)
  69. cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
  70. cls.homeserver_url_map = {server: URL(url) for server, url
  71. in cls.config["bridge.double_puppet_server_map"].items()}
  72. cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
  73. cls.login_shared_secret_map = {server: secret.encode("utf-8") for server, secret
  74. in cls.config["bridge.login_shared_secret_map"].items()}
  75. cls.login_device_name = "Signal Bridge"
  76. return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
  77. def intent_for(self, portal: 'p.Portal') -> IntentAPI:
  78. if portal.chat_id == self.address:
  79. return self.default_mxid_intent
  80. return self.intent
  81. @property
  82. def is_registered(self) -> bool:
  83. return self.uuid_registered if self.uuid is not None else self.number_registered
  84. @is_registered.setter
  85. def is_registered(self, value: bool) -> None:
  86. if self.uuid is not None:
  87. self.uuid_registered = value
  88. else:
  89. self.number_registered = value
  90. @property
  91. def address(self) -> Address:
  92. return Address(uuid=self.uuid, number=self.number)
  93. async def handle_uuid_receive(self, uuid: UUID) -> None:
  94. async with self._uuid_lock:
  95. if self.uuid:
  96. # Received UUID was handled while this call was waiting
  97. return
  98. await self._handle_uuid_receive(uuid)
  99. async def _handle_uuid_receive(self, uuid: UUID) -> None:
  100. self.log.debug(f"Found UUID for user: {uuid}")
  101. user = await u.User.get_by_username(self.number)
  102. if user and not user.uuid:
  103. user.uuid = self.uuid
  104. await user.update()
  105. await self._set_uuid(uuid)
  106. self.by_uuid[self.uuid] = self
  107. async for portal in p.Portal.find_private_chats_with(Address(number=self.number)):
  108. self.log.trace(f"Updating chat_id of private chat portal {portal.receiver}")
  109. portal.handle_uuid_receive(self.uuid)
  110. prev_intent = self.default_mxid_intent
  111. self.default_mxid = self.get_mxid_from_id(self.address)
  112. self.default_mxid_intent = self.az.intent.user(self.default_mxid)
  113. self.intent = self._fresh_intent()
  114. await self.default_mxid_intent.ensure_registered()
  115. if self.name:
  116. await self.default_mxid_intent.set_displayname(self.name)
  117. self.log = Puppet.log.getChild(str(uuid))
  118. self.log.debug(f"Migrating memberships {prev_intent.mxid}"
  119. f" -> {self.default_mxid_intent.mxid}")
  120. try:
  121. joined_rooms = await prev_intent.get_joined_rooms()
  122. except MForbidden as e:
  123. self.log.debug(f"Got MForbidden ({e.message}) when getting joined rooms of old mxid, "
  124. "assuming there are no rooms to rejoin")
  125. return
  126. for room_id in joined_rooms:
  127. await prev_intent.invite_user(room_id, self.default_mxid)
  128. await prev_intent.leave_room(room_id)
  129. await self.default_mxid_intent.join_room_by_id(room_id)
  130. async def update_info(self, info: Union[Profile, Contact, Address]) -> None:
  131. if isinstance(info, (Contact, Address)):
  132. address = info.address if isinstance(info, Contact) else info
  133. if address.uuid and not self.uuid:
  134. await self.handle_uuid_receive(address.uuid)
  135. contact_names = self.config["bridge.contact_list_names"]
  136. if isinstance(info, Profile) and contact_names != "prefer" and info.profile_name:
  137. name = info.profile_name
  138. elif isinstance(info, (Contact, Profile)) and contact_names != "disallow":
  139. name = info.name
  140. else:
  141. name = None
  142. async with self._update_info_lock:
  143. update = False
  144. if name is not None or self.name is None:
  145. update = await self._update_name(name) or update
  146. if update:
  147. await self.update()
  148. @staticmethod
  149. def fmt_phone(number: str) -> str:
  150. if phonenumbers is None:
  151. return number
  152. parsed = phonenumbers.parse(number)
  153. fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
  154. return phonenumbers.format_number(parsed, fmt)
  155. @classmethod
  156. def _get_displayname(cls, address: Address, name: Optional[str]) -> str:
  157. names = name.split("\x00") if name else []
  158. data = {
  159. "first_name": names[0] if len(names) > 0 else "",
  160. "last_name": names[-1] if len(names) > 1 else "",
  161. "full_name": " ".join(names),
  162. "phone": cls.fmt_phone(address.number) if address.number else None,
  163. "uuid": str(address.uuid) if address.uuid else None,
  164. "displayname": "Unknown user",
  165. }
  166. for pref in cls.config["bridge.displayname_preference"]:
  167. value = data.get(pref.replace(" ", "_"))
  168. if value:
  169. data["displayname"] = value
  170. break
  171. return cls.config["bridge.displayname_template"].format(**data)
  172. async def _update_name(self, name: Optional[str]) -> bool:
  173. name = self._get_displayname(self.address, name)
  174. if name != self.name:
  175. self.name = name
  176. await self.default_mxid_intent.set_displayname(self.name)
  177. self.loop.create_task(self._update_portal_names())
  178. return True
  179. return False
  180. async def _update_portal_names(self) -> None:
  181. async for portal in p.Portal.find_private_chats_with(self.address):
  182. if portal.receiver == self.number:
  183. # This is a note to self chat, don't change the name
  184. continue
  185. await portal.update_puppet_name(self.name)
  186. async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
  187. portal = await p.Portal.get_by_mxid(room_id)
  188. return portal and portal.chat_id != self.uuid
  189. # region Database getters
  190. def _add_to_cache(self) -> None:
  191. if self.uuid:
  192. self.by_uuid[self.uuid] = self
  193. if self.number:
  194. self.by_number[self.number] = self
  195. if self.custom_mxid:
  196. self.by_custom_mxid[self.custom_mxid] = self
  197. async def save(self) -> None:
  198. await self.update()
  199. @classmethod
  200. async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
  201. address = cls.get_id_from_mxid(mxid)
  202. if not address:
  203. return None
  204. return await cls.get_by_address(address, create)
  205. @classmethod
  206. async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
  207. try:
  208. return cls.by_custom_mxid[mxid]
  209. except KeyError:
  210. pass
  211. puppet = cast(cls, await super().get_by_custom_mxid(mxid))
  212. if puppet:
  213. puppet._add_to_cache()
  214. return puppet
  215. return None
  216. @classmethod
  217. def get_id_from_mxid(cls, mxid: UserID) -> Optional[Address]:
  218. identifier = cls.mxid_template.parse(mxid)
  219. if not identifier:
  220. return None
  221. if identifier.startswith("phone_"):
  222. return Address(number="+" + identifier[len("phone_"):])
  223. else:
  224. try:
  225. return Address(uuid=UUID(identifier.upper()))
  226. except ValueError:
  227. return None
  228. @classmethod
  229. def get_mxid_from_id(cls, address: Address) -> UserID:
  230. if address.uuid:
  231. identifier = str(address.uuid).lower()
  232. elif address.number:
  233. identifier = f"phone_{address.number.lstrip('+')}"
  234. else:
  235. raise ValueError("Empty address")
  236. return UserID(cls.mxid_template.format_full(identifier))
  237. @classmethod
  238. async def get_by_address(cls, address: Address, create: bool = True) -> Optional['Puppet']:
  239. puppet = await cls._get_by_address(address, create)
  240. if puppet and address.uuid and not puppet.uuid:
  241. # We found a UUID for this user, store it ASAP
  242. await puppet.handle_uuid_receive(address.uuid)
  243. return puppet
  244. @classmethod
  245. async def _get_by_address(cls, address: Address, create: bool = True) -> Optional['Puppet']:
  246. if not address.is_valid:
  247. raise ValueError("Empty address")
  248. if address.uuid:
  249. try:
  250. return cls.by_uuid[address.uuid]
  251. except KeyError:
  252. pass
  253. if address.number:
  254. try:
  255. return cls.by_number[address.number]
  256. except KeyError:
  257. pass
  258. puppet = cast(cls, await super().get_by_address(address))
  259. if puppet is not None:
  260. puppet._add_to_cache()
  261. return puppet
  262. if create:
  263. puppet = cls(address.uuid, address.number)
  264. await puppet.insert()
  265. puppet._add_to_cache()
  266. return puppet
  267. return None
  268. @classmethod
  269. async def all_with_custom_mxid(cls) -> AsyncGenerator['Puppet', None]:
  270. puppets = await super().all_with_custom_mxid()
  271. puppet: cls
  272. for index, puppet in enumerate(puppets):
  273. try:
  274. yield cls.by_uuid[puppet.uuid]
  275. except KeyError:
  276. try:
  277. yield cls.by_number[puppet.number]
  278. except KeyError:
  279. puppet._add_to_cache()
  280. yield puppet
  281. # endregion