puppet.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  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 (
  17. TYPE_CHECKING,
  18. AsyncGenerator,
  19. AsyncIterable,
  20. Awaitable,
  21. Dict,
  22. Optional,
  23. Tuple,
  24. Union,
  25. cast,
  26. )
  27. from uuid import UUID
  28. import asyncio
  29. import hashlib
  30. import os.path
  31. from mautrix.appservice import IntentAPI
  32. from mautrix.bridge import BasePuppet, async_getter_lock
  33. from mautrix.errors import MForbidden
  34. from mautrix.types import (
  35. ContentURI,
  36. EventType,
  37. PowerLevelStateEventContent,
  38. RoomID,
  39. SyncToken,
  40. UserID,
  41. )
  42. from mautrix.util.simple_template import SimpleTemplate
  43. from yarl import URL
  44. from mausignald.types import Address, Contact, Profile
  45. from . import portal as p
  46. from . import user as u
  47. from .config import Config
  48. from .db import Puppet as DBPuppet
  49. if TYPE_CHECKING:
  50. from .__main__ import SignalBridge
  51. try:
  52. import phonenumbers
  53. except ImportError:
  54. phonenumbers = None
  55. class Puppet(DBPuppet, BasePuppet):
  56. by_uuid: Dict[UUID, "Puppet"] = {}
  57. by_number: Dict[str, "Puppet"] = {}
  58. by_custom_mxid: Dict[UserID, "Puppet"] = {}
  59. hs_domain: str
  60. mxid_template: SimpleTemplate[str]
  61. config: Config
  62. default_mxid_intent: IntentAPI
  63. default_mxid: UserID
  64. _uuid_lock: asyncio.Lock
  65. _update_info_lock: asyncio.Lock
  66. def __init__(
  67. self,
  68. uuid: Optional[UUID],
  69. number: Optional[str],
  70. name: Optional[str] = None,
  71. avatar_url: Optional[ContentURI] = None,
  72. avatar_hash: Optional[str] = None,
  73. name_set: bool = False,
  74. avatar_set: bool = False,
  75. uuid_registered: bool = False,
  76. number_registered: bool = False,
  77. custom_mxid: Optional[UserID] = None,
  78. access_token: Optional[str] = None,
  79. next_batch: Optional[SyncToken] = None,
  80. base_url: Optional[URL] = None,
  81. ) -> None:
  82. super().__init__(
  83. uuid=uuid,
  84. number=number,
  85. name=name,
  86. avatar_url=avatar_url,
  87. avatar_hash=avatar_hash,
  88. name_set=name_set,
  89. avatar_set=avatar_set,
  90. uuid_registered=uuid_registered,
  91. number_registered=number_registered,
  92. custom_mxid=custom_mxid,
  93. access_token=access_token,
  94. next_batch=next_batch,
  95. base_url=base_url,
  96. )
  97. self.log = self.log.getChild(str(uuid) if uuid else number)
  98. self.default_mxid = self.get_mxid_from_id(self.address)
  99. self.default_mxid_intent = self.az.intent.user(self.default_mxid)
  100. self.intent = self._fresh_intent()
  101. self._uuid_lock = asyncio.Lock()
  102. self._update_info_lock = asyncio.Lock()
  103. @classmethod
  104. def init_cls(cls, bridge: "SignalBridge") -> AsyncIterable[Awaitable[None]]:
  105. cls.config = bridge.config
  106. cls.loop = bridge.loop
  107. cls.mx = bridge.matrix
  108. cls.az = bridge.az
  109. cls.hs_domain = cls.config["homeserver.domain"]
  110. cls.mxid_template = SimpleTemplate(
  111. cls.config["bridge.username_template"],
  112. "userid",
  113. prefix="@",
  114. suffix=f":{cls.hs_domain}",
  115. type=str,
  116. )
  117. cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
  118. cls.homeserver_url_map = {
  119. server: URL(url)
  120. for server, url in cls.config["bridge.double_puppet_server_map"].items()
  121. }
  122. cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
  123. cls.login_shared_secret_map = {
  124. server: secret.encode("utf-8")
  125. for server, secret in cls.config["bridge.login_shared_secret_map"].items()
  126. }
  127. cls.login_device_name = "Signal Bridge"
  128. return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
  129. def intent_for(self, portal: "p.Portal") -> IntentAPI:
  130. if portal.chat_id == self.address:
  131. return self.default_mxid_intent
  132. return self.intent
  133. @property
  134. def is_registered(self) -> bool:
  135. return self.uuid_registered if self.uuid is not None else self.number_registered
  136. @is_registered.setter
  137. def is_registered(self, value: bool) -> None:
  138. if self.uuid is not None:
  139. self.uuid_registered = value
  140. else:
  141. self.number_registered = value
  142. @property
  143. def address(self) -> Address:
  144. return Address(uuid=self.uuid, number=self.number)
  145. async def handle_uuid_receive(self, uuid: UUID) -> None:
  146. async with self._uuid_lock:
  147. if self.uuid:
  148. # Received UUID was handled while this call was waiting
  149. return
  150. await self._handle_uuid_receive(uuid)
  151. async def handle_number_receive(self, number: str) -> None:
  152. async with self._uuid_lock:
  153. if self.number:
  154. return
  155. self.number = number
  156. self.by_number[self.number] = self
  157. await self._set_number(number)
  158. async for portal in p.Portal.find_private_chats_with(Address(number=number)):
  159. self.log.trace(f"Updating chat_id of private chat portal {portal.receiver}")
  160. portal.handle_uuid_receive(self.uuid)
  161. prev_mxid = self.get_mxid_from_id(Address(number=number))
  162. if await self.az.state_store.is_registered(prev_mxid):
  163. prev_intent = self.az.intent.user(prev_mxid)
  164. await self._migrate_memberships(prev_intent, self.default_mxid_intent)
  165. async def _handle_uuid_receive(self, uuid: UUID) -> None:
  166. self.log.debug(f"Found UUID for user: {uuid}")
  167. user = await u.User.get_by_username(self.number)
  168. if user and not user.uuid:
  169. user.uuid = self.uuid
  170. user.by_uuid[user.uuid] = user
  171. await user.update()
  172. self.uuid = uuid
  173. self.by_uuid[self.uuid] = self
  174. await self._set_uuid(uuid)
  175. async for portal in p.Portal.find_private_chats_with(Address(number=self.number)):
  176. self.log.trace(f"Updating chat_id of private chat portal {portal.receiver}")
  177. portal.handle_uuid_receive(self.uuid)
  178. prev_intent = self.default_mxid_intent
  179. self.default_mxid = self.get_mxid_from_id(self.address)
  180. self.default_mxid_intent = self.az.intent.user(self.default_mxid)
  181. self.intent = self._fresh_intent()
  182. await self.default_mxid_intent.ensure_registered()
  183. if self.name:
  184. await self.default_mxid_intent.set_displayname(self.name)
  185. self.log = Puppet.log.getChild(str(uuid))
  186. await self._migrate_memberships(prev_intent, self.default_mxid_intent)
  187. async def _migrate_memberships(self, prev_intent: IntentAPI, new_intent: IntentAPI) -> None:
  188. self.log.debug(f"Migrating memberships {prev_intent.mxid} -> {new_intent.mxid}")
  189. try:
  190. joined_rooms = await prev_intent.get_joined_rooms()
  191. except MForbidden as e:
  192. self.log.debug(
  193. f"Got MForbidden ({e.message}) when getting joined rooms of old mxid, "
  194. "assuming there are no rooms to rejoin"
  195. )
  196. return
  197. for room_id in joined_rooms:
  198. await prev_intent.invite_user(room_id, self.default_mxid)
  199. await self._migrate_powers(prev_intent, new_intent, room_id)
  200. await prev_intent.leave_room(room_id)
  201. await new_intent.join_room_by_id(room_id)
  202. async def _migrate_powers(
  203. self, prev_intent: IntentAPI, new_intent: IntentAPI, room_id: RoomID
  204. ) -> None:
  205. try:
  206. powers: PowerLevelStateEventContent
  207. powers = await prev_intent.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
  208. user_level = powers.get_user_level(prev_intent.mxid)
  209. pl_state_level = powers.get_event_level(EventType.ROOM_POWER_LEVELS)
  210. if user_level >= pl_state_level > powers.users_default:
  211. powers.ensure_user_level(new_intent.mxid, user_level)
  212. await prev_intent.send_state_event(room_id, EventType.ROOM_POWER_LEVELS, powers)
  213. except Exception:
  214. self.log.warning("Failed to migrate power levels", exc_info=True)
  215. async def update_info(self, info: Union[Profile, Contact, Address]) -> None:
  216. address = info.address if isinstance(info, (Contact, Profile)) else info
  217. if address.uuid and not self.uuid:
  218. await self.handle_uuid_receive(address.uuid)
  219. if address.number and not self.number:
  220. await self.handle_number_receive(address.number)
  221. contact_names = self.config["bridge.contact_list_names"]
  222. if isinstance(info, Profile) and contact_names != "prefer" and info.profile_name:
  223. name = info.profile_name
  224. elif isinstance(info, (Contact, Profile)) and contact_names != "disallow":
  225. name = info.name
  226. if not name and isinstance(info, Profile) and info.profile_name:
  227. # Contact list name is preferred, but was not found, fall back to profile
  228. name = info.profile_name
  229. else:
  230. name = None
  231. async with self._update_info_lock:
  232. update = False
  233. if name is not None or self.name is None:
  234. update = await self._update_name(name) or update
  235. if isinstance(info, Profile):
  236. update = await self._update_avatar(info.avatar) or update
  237. elif contact_names != "disallow" and self.number:
  238. update = await self._update_avatar(f"contact-{self.number}") or update
  239. if update:
  240. await self.update()
  241. asyncio.create_task(self._update_portal_meta())
  242. @staticmethod
  243. def fmt_phone(number: str) -> str:
  244. if phonenumbers is None:
  245. return number
  246. parsed = phonenumbers.parse(number)
  247. fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
  248. return phonenumbers.format_number(parsed, fmt)
  249. @classmethod
  250. def _get_displayname(cls, address: Address, name: Optional[str]) -> str:
  251. names = name.split("\x00") if name else []
  252. data = {
  253. "first_name": names[0] if len(names) > 0 else "",
  254. "last_name": names[-1] if len(names) > 1 else "",
  255. "full_name": " ".join(names),
  256. "phone": cls.fmt_phone(address.number) if address.number else None,
  257. "uuid": str(address.uuid) if address.uuid else None,
  258. "displayname": "Unknown user",
  259. }
  260. for pref in cls.config["bridge.displayname_preference"]:
  261. value = data.get(pref.replace(" ", "_"))
  262. if value:
  263. data["displayname"] = value
  264. break
  265. return cls.config["bridge.displayname_template"].format(**data)
  266. async def _update_name(self, name: Optional[str]) -> bool:
  267. name = self._get_displayname(self.address, name)
  268. if name != self.name or not self.name_set:
  269. self.name = name
  270. try:
  271. await self.default_mxid_intent.set_displayname(self.name)
  272. self.name_set = True
  273. except Exception:
  274. self.log.exception("Error setting displayname")
  275. self.name_set = False
  276. return True
  277. return False
  278. @staticmethod
  279. async def upload_avatar(
  280. self: Union["Puppet", "p.Portal"],
  281. path: str,
  282. intent: IntentAPI,
  283. ) -> Union[bool, Tuple[str, ContentURI]]:
  284. if not path:
  285. return False
  286. if not path.startswith("/"):
  287. path = os.path.join(self.config["signal.avatar_dir"], path)
  288. try:
  289. with open(path, "rb") as file:
  290. data = file.read()
  291. except FileNotFoundError:
  292. return False
  293. if not data:
  294. return False
  295. new_hash = hashlib.sha256(data).hexdigest()
  296. if self.avatar_set and new_hash == self.avatar_hash:
  297. return False
  298. mxc = await intent.upload_media(data)
  299. return new_hash, mxc
  300. async def _update_avatar(self, path: str) -> bool:
  301. res = await Puppet.upload_avatar(self, path, self.default_mxid_intent)
  302. if res is False:
  303. return False
  304. self.avatar_hash, self.avatar_url = res
  305. try:
  306. await self.default_mxid_intent.set_avatar_url(self.avatar_url)
  307. self.avatar_set = True
  308. except Exception:
  309. self.log.exception("Error setting avatar")
  310. self.avatar_set = False
  311. return True
  312. async def _update_portal_meta(self) -> None:
  313. async for portal in p.Portal.find_private_chats_with(self.address):
  314. if portal.receiver == self.number:
  315. # This is a note to self chat, don't change the name
  316. continue
  317. try:
  318. await portal.update_puppet_name(self.name)
  319. await portal.update_puppet_avatar(self.avatar_hash, self.avatar_url)
  320. except Exception:
  321. self.log.exception(f"Error updating portal meta for {portal.receiver}")
  322. async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
  323. portal = await p.Portal.get_by_mxid(room_id)
  324. return portal and portal.chat_id != self.uuid
  325. # region Database getters
  326. def _add_to_cache(self) -> None:
  327. if self.uuid:
  328. self.by_uuid[self.uuid] = self
  329. if self.number:
  330. self.by_number[self.number] = self
  331. if self.custom_mxid:
  332. self.by_custom_mxid[self.custom_mxid] = self
  333. async def save(self) -> None:
  334. await self.update()
  335. @classmethod
  336. async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional["Puppet"]:
  337. address = cls.get_id_from_mxid(mxid)
  338. if not address:
  339. return None
  340. return await cls.get_by_address(address, create)
  341. @classmethod
  342. @async_getter_lock
  343. async def get_by_custom_mxid(cls, mxid: UserID) -> Optional["Puppet"]:
  344. try:
  345. return cls.by_custom_mxid[mxid]
  346. except KeyError:
  347. pass
  348. puppet = cast(cls, await super().get_by_custom_mxid(mxid))
  349. if puppet:
  350. puppet._add_to_cache()
  351. return puppet
  352. return None
  353. @classmethod
  354. def get_id_from_mxid(cls, mxid: UserID) -> Optional[Address]:
  355. identifier = cls.mxid_template.parse(mxid)
  356. if not identifier:
  357. return None
  358. if identifier.startswith("phone_"):
  359. return Address(number="+" + identifier[len("phone_") :])
  360. else:
  361. try:
  362. return Address(uuid=UUID(identifier.upper()))
  363. except ValueError:
  364. return None
  365. @classmethod
  366. def get_mxid_from_id(cls, address: Address) -> UserID:
  367. if address.uuid:
  368. identifier = str(address.uuid).lower()
  369. elif address.number:
  370. identifier = f"phone_{address.number.lstrip('+')}"
  371. else:
  372. raise ValueError("Empty address")
  373. return UserID(cls.mxid_template.format_full(identifier))
  374. @classmethod
  375. @async_getter_lock
  376. async def get_by_address(cls, address: Address, create: bool = True) -> Optional["Puppet"]:
  377. puppet = await cls._get_by_address(address, create)
  378. if puppet and address.uuid and not puppet.uuid:
  379. # We found a UUID for this user, store it ASAP
  380. await puppet.handle_uuid_receive(address.uuid)
  381. return puppet
  382. @classmethod
  383. async def _get_by_address(cls, address: Address, create: bool = True) -> Optional["Puppet"]:
  384. if not address.is_valid:
  385. raise ValueError("Empty address")
  386. if address.uuid:
  387. try:
  388. return cls.by_uuid[address.uuid]
  389. except KeyError:
  390. pass
  391. if address.number:
  392. try:
  393. return cls.by_number[address.number]
  394. except KeyError:
  395. pass
  396. puppet = cast(cls, await super().get_by_address(address))
  397. if puppet is not None:
  398. puppet._add_to_cache()
  399. return puppet
  400. if create:
  401. puppet = cls(address.uuid, address.number)
  402. await puppet.insert()
  403. puppet._add_to_cache()
  404. return puppet
  405. return None
  406. @classmethod
  407. async def all_with_custom_mxid(cls) -> AsyncGenerator["Puppet", None]:
  408. puppets = await super().all_with_custom_mxid()
  409. puppet: cls
  410. for index, puppet in enumerate(puppets):
  411. try:
  412. yield cls.by_uuid[puppet.uuid]
  413. except KeyError:
  414. try:
  415. yield cls.by_number[puppet.number]
  416. except KeyError:
  417. puppet._add_to_cache()
  418. yield puppet
  419. # endregion